-package info.mechyrdia.robot
-
-import info.mechyrdia.Configuration
-import info.mechyrdia.MainDomainName
-import info.mechyrdia.data.*
-import info.mechyrdia.lore.RobotFactbookLoader
-import io.ktor.client.*
-import io.ktor.client.engine.java.*
-import io.ktor.client.plugins.*
-import io.ktor.client.plugins.contentnegotiation.*
-import io.ktor.client.plugins.logging.*
-import io.ktor.client.request.*
-import io.ktor.http.*
-import io.ktor.serialization.kotlinx.json.*
-import kotlinx.coroutines.*
-import kotlinx.coroutines.flow.*
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-import org.slf4j.Logger
-import org.slf4j.LoggerFactory
-import java.time.Instant
-import kotlin.random.Random
-import kotlin.time.Duration.Companion.minutes
-
-val RobotGlobalsId = Id<RobotGlobals>("RobotGlobalsInstance")
-
-@Serializable
-data class RobotGlobals(
- @SerialName(MONGODB_ID_KEY)
- override val id: Id<RobotGlobals> = RobotGlobalsId,
-
- val lastFileUpload: @Serializable(with = InstantNullableSerializer::class) Instant? = null,
- val fileIdMap: Map<String, RobotFileId> = emptyMap(),
- val vectorStoreId: RobotVectorStoreId? = null,
- val assistantId: RobotAssistantId? = null,
- val ongoingThreadIds: Set<RobotThreadId> = emptySet(),
-) : DataDocument<RobotGlobals> {
- suspend fun save(): RobotGlobals {
- set(this)
- return this
- }
-
- companion object : TableHolder<RobotGlobals> {
- override val Table = DocumentTable<RobotGlobals>()
-
- suspend fun get() = Table.get(RobotGlobalsId)
- suspend fun set(instance: RobotGlobals) = Table.put(instance)
- suspend fun delete() = Table.del(RobotGlobalsId)
-
- override suspend fun initialize() = Unit
- }
-}
-
-private fun RobotGlobals.plusThread(threadId: RobotThreadId) = copy(
- ongoingThreadIds = ongoingThreadIds + threadId
-)
-
-private fun RobotGlobals.minusThread(threadId: RobotThreadId) = copy(
- ongoingThreadIds = ongoingThreadIds - threadId
-)
-
-enum class RobotServiceStatus {
- NOT_CONFIGURED,
- LOADING,
- FAILED,
- READY,
-}
-
-class RobotService(
- token: String,
- orgId: String,
- project: String?,
-) {
- private val robotClient = RobotClient(
- HttpClient(Java) {
- defaultRequest {
- header(HttpHeaders.Authorization, "Bearer $token")
- header("OpenAI-Organization", orgId)
- project?.let { header("OpenAI-Project", it) }
- header("OpenAI-Beta", "assistants=v2")
- }
-
- install(ContentNegotiation) {
- json(JsonRobotCodec)
- }
-
- Logging {
- level = LogLevel.INFO
- sanitizeHeader("<OPENAI TOKEN>") { it == HttpHeaders.Authorization }
- }
-
- install(HttpRequestRetry) {
- retryOnExceptionOrServerErrors(5)
- delayMillis { retry ->
- (1 shl (retry - 1)) * 1000L + Random.nextLong(250L, 750L)
- }
- }
-
- expectSuccess = true
-
- install(RobotRateLimiter)
- }
- )
-
- private suspend fun createThread(): RobotThreadId {
- return robotClient.createThread(RobotCreateThreadRequest()).id.also { threadId ->
- (RobotGlobals.get() ?: RobotGlobals()).plusThread(threadId).save()
- }
- }
-
- private suspend fun deleteThread(threadId: RobotThreadId) {
- robotClient.deleteThread(threadId)
- (RobotGlobals.get() ?: RobotGlobals()).minusThread(threadId).save()
- }
-
- private suspend fun RobotGlobals.gcOldThreads(): RobotGlobals {
- for (threadId in ongoingThreadIds)
- try {
- robotClient.deleteThread(threadId)
- } catch (ex: ClientRequestException) {
- logger.warn("Unable to delete thread at ID $threadId", ex)
- }
- return copy(ongoingThreadIds = emptySet())
- }
-
- private suspend fun updateFiles(prevGlobals: RobotGlobals?, onNewFileId: (suspend (RobotFileId) -> Unit)? = null): RobotGlobals {
- val robotGlobals = prevGlobals ?: RobotGlobals()
-
- val fileIdMap = buildMap<String, RobotFileId> {
- putAll(robotGlobals.fileIdMap)
-
- val factbooks = robotGlobals.lastFileUpload?.let {
- RobotFactbookLoader.loadAllFactbooksSince(it)
- } ?: RobotFactbookLoader.loadAllFactbooks()
-
- for ((name, text) in factbooks) {
- remove(name)?.let { oldId ->
- try {
- robotClient.deleteFile(oldId)
- } catch (ex: ClientRequestException) {
- logger.warn("Unable to delete file $name at ID $oldId", ex)
- }
- }
-
- val newId = robotClient.uploadFile(
- "assistants",
- FileUpload(
- text.toByteArray(),
- ContentType.Text.Plain.withCharset(Charsets.UTF_8),
- name.toOpenAiName()
- )
- ).id
-
- this[name] = newId
- onNewFileId?.invoke(newId)
-
- logger.info("Factbook $name has been uploaded")
- }
- }
-
- return robotGlobals.copy(lastFileUpload = Instant.now(), fileIdMap = fileIdMap).save()
- }
-
- suspend fun initialize() {
- var robotGlobals = updateFiles(RobotGlobals.get()?.gcOldThreads())
-
- val vectorStoreId = robotGlobals.vectorStoreId ?: robotClient.createVectorStore(
- RobotCreateVectorStoreRequest(
- name = "lore_documents",
- fileIds = robotGlobals.fileIdMap.values.toList(),
- )
- ).id.also { vsId ->
- robotGlobals = robotGlobals.copy(vectorStoreId = vsId).save()
- }
-
- logger.info("Vector store has been created")
-
- poll {
- robotClient.getVectorStore(vectorStoreId).status == "completed"
- }
-
- logger.info("Vector store creation is complete")
-
- if (robotGlobals.assistantId == null)
- robotGlobals = robotGlobals.copy(
- assistantId = robotClient.createAssistant(
- RobotCreateAssistantRequest(
- model = "gpt-4o",
- name = "Natural-language Universal Knowledge Engine",
- instructions = "You are a helpful interactive encyclopedia, able to answer questions with information from the provided files",
- tools = listOf(
- RobotCreateAssistantRequestTool("file_search")
- ),
- toolResources = RobotCreateAssistantRequestToolResources(
- fileSearch = RobotCreateAssistantRequestFileSearchResources(
- vectorStoreIds = listOf(vectorStoreId)
- )
- ),
- temperature = 1.0
- )
- ).id
- ).save()
-
- logger.info("Assistant has been created")
- }
-
- suspend fun performMaintenance() {
- var robotGlobals = RobotGlobals.get() ?: RobotGlobals()
-
- val vectorStoreId = robotGlobals.vectorStoreId ?: robotClient.createVectorStore(
- RobotCreateVectorStoreRequest(
- name = "lore_documents",
- fileIds = robotGlobals.fileIdMap.values.toList(),
- )
- ).id.also { vsId ->
- robotGlobals = robotGlobals.copy(vectorStoreId = vsId).save()
- }
-
- updateFiles(robotGlobals) { fileId ->
- robotClient.addFileToVectorStore(vectorStoreId, fileId)
- }
-
- logger.info("Vector store has been updated")
-
- poll {
- robotClient.getVectorStore(vectorStoreId).fileCounts.inProgress == 0
- }
-
- logger.info("Vector store update is complete")
- }
-
- suspend fun reset() {
- RobotGlobals.get()?.gcOldThreads()?.copy(
- lastFileUpload = null,
- fileIdMap = emptyMap(),
- vectorStoreId = null,
- assistantId = null,
- )?.save()
-
- while (true) {
- val assistants = robotClient.listAssistants().data
- if (assistants.isEmpty()) break
-
- assistants.map { it.id }.forEach {
- robotClient.deleteAssistant(it)
- }
- }
-
- while (true) {
- val vectorStores = robotClient.listVectorStores().data
- if (vectorStores.isEmpty()) break
-
- vectorStores.map { it.id }.forEach {
- robotClient.deleteVectorStore(it)
- }
- }
-
- robotClient.listFiles().data.map { it.id }.forEach {
- robotClient.deleteFile(it)
- }
-
- initialize()
- }
-
- inner class Conversation(private val nationId: Id<NationData>) {
- private var assistantId: RobotAssistantId? = null
- private var threadId: RobotThreadId? = null
-
- suspend fun send(userMessage: String): Flow<RobotConversationMessage> {
- val assistant = assistantId ?: pollValue { RobotGlobals.get()?.assistantId }
- .also { assistantId = it }
-
- val thread = threadId ?: createThread().also { threadId = it }
-
- val messages = listOf(
- RobotCreateThreadRequestMessage(
- role = "user",
- content = userMessage
- )
- )
-
- val tokenTracker = ConversationMessageTokenTracker()
-
- return flow {
- emit(RobotConversationMessage.User(userMessage))
-
- val annotationTargets = mutableListOf<Deferred<String>>()
- val collectionScope = CoroutineScope(currentCoroutineContext())
-
- robotClient.createRun(thread, assistant, messages)
- .filter { it.event == "thread.message.delta" }
- .mapNotNull { it.data }
- .map { JsonRobotCodec.decodeFromString(RobotMessageDelta.serializer(), it) }
- .collect { eventData ->
- val annotationTexts = eventData.delta.content.flatMap { it.text.annotations }.map { annotation ->
- val annotationIndex = annotationTargets.size
- annotationTargets.add(collectionScope.async {
- val fileName = robotClient.getFile(annotation.fileCitation.fileId).filename.fromOpenAiName()
- val fileText = annotation.fileCitation.quote.let { if (it.isNotBlank()) ": $it" else it }
- "$MainDomainName/lore/$fileName$fileText"
- })
- annotation.text to " [${annotationIndex + 1}]"
- }
-
- val contents = eventData.delta.content.joinToString(separator = "") { textContent ->
- textContent.text.value
- }
-
- val replacedContents = annotationTexts.fold(contents) { text, (replace, replaceWith) ->
- text.replace(replace, replaceWith)
- }
-
- emit(RobotConversationMessage.Robot(replacedContents))
- }
-
- emit(RobotConversationMessage.Cite(annotationTargets.awaitAll()))
-
- emit(RobotConversationMessage.Ready)
- }.onEach { message ->
- tokenTracker.addMessage(message)
- }.onCompletion { _ ->
- RobotUser.addTokens(nationId, tokenTracker.calculateTokens())
- }
- }
-
- suspend fun isExhausted(): Boolean {
- val usedTokens = RobotUser.getTokens(nationId)
- val tokenLimit = RobotUser.getMaxTokens(nationId)
- return usedTokens >= tokenLimit
- }
-
- suspend fun close() {
- threadId?.let { deleteThread(it) }
- }
- }
-
- companion object {
- private val logger: Logger = LoggerFactory.getLogger(RobotService::class.java)
-
- private val maintenanceScope = CoroutineScope(SupervisorJob() + CoroutineName("robot-service-maintenance"))
-
- private val instanceHolder by lazy {
- CoroutineScope(CoroutineName("robot-service-initialization")).async {
- Configuration.Current.openAi?.let { (token, orgId, project) ->
- RobotService(token, orgId, project).apply {
- initialize()
- }
- }
- }
- }
-
- var status: RobotServiceStatus = if (Configuration.Current.openAi != null) RobotServiceStatus.LOADING else RobotServiceStatus.NOT_CONFIGURED
- private set
-
- suspend fun getInstance() = try {
- instanceHolder.await()
- } catch (ex: Exception) {
- null
- }
-
- fun initialize() {
- instanceHolder.invokeOnCompletion { ex ->
- status = if (ex != null) {
- logger.error("RobotService failed to initialize", ex)
- RobotServiceStatus.FAILED
- } else {
- logger.info("RobotService successfully initialized")
- RobotServiceStatus.READY
- }
- }
-
- maintenanceScope.launch {
- getInstance()?.let { instance ->
- while (true) {
- delay(30.minutes)
-
- launch(SupervisorJob(currentCoroutineContext().job)) {
- instance.performMaintenance()
- }
- }
- }
- }
- }
- }
-}
-
-@Serializable
-sealed class RobotConversationMessage {
- @Serializable
- @SerialName("ready")
- data object Ready : RobotConversationMessage()
-
- @Serializable
- @SerialName("user")
- data class User(val text: String) : RobotConversationMessage()
-
- @Serializable
- @SerialName("robot")
- data class Robot(val text: String) : RobotConversationMessage()
-
- @Serializable
- @SerialName("cite")
- data class Cite(val urls: List<String>) : RobotConversationMessage()
-}
+package info.mechyrdia.robot\r
+\r
+import info.mechyrdia.Configuration\r
+import info.mechyrdia.MainDomainName\r
+import info.mechyrdia.OpenAiConfig\r
+import info.mechyrdia.data.*\r
+import info.mechyrdia.lore.RobotFactbookLoader\r
+import io.ktor.client.*\r
+import io.ktor.client.engine.java.*\r
+import io.ktor.client.plugins.*\r
+import io.ktor.client.plugins.contentnegotiation.*\r
+import io.ktor.client.plugins.logging.*\r
+import io.ktor.client.request.*\r
+import io.ktor.http.*\r
+import io.ktor.serialization.kotlinx.json.*\r
+import kotlinx.coroutines.*\r
+import kotlinx.coroutines.flow.*\r
+import kotlinx.serialization.SerialName\r
+import kotlinx.serialization.Serializable\r
+import org.slf4j.Logger\r
+import org.slf4j.LoggerFactory\r
+import java.time.Instant\r
+import kotlin.random.Random\r
+import kotlin.time.Duration.Companion.minutes\r
+\r
+val RobotGlobalsId = Id<RobotGlobals>("RobotGlobalsInstance")\r
+\r
+@Serializable\r
+data class RobotGlobals(\r
+ @SerialName(MONGODB_ID_KEY)\r
+ override val id: Id<RobotGlobals> = RobotGlobalsId,\r
+ \r
+ val lastFileUpload: @Serializable(with = InstantNullableSerializer::class) Instant? = null,\r
+ val fileIdMap: Map<String, RobotFileId> = emptyMap(),\r
+ val vectorStoreId: RobotVectorStoreId? = null,\r
+ val assistantId: RobotAssistantId? = null,\r
+ val ongoingThreadIds: Set<RobotThreadId> = emptySet(),\r
+) : DataDocument<RobotGlobals> {\r
+ suspend fun save(): RobotGlobals {\r
+ set(this)\r
+ return this\r
+ }\r
+ \r
+ companion object : TableHolder<RobotGlobals> {\r
+ override val Table = DocumentTable<RobotGlobals>()\r
+ \r
+ suspend fun get() = Table.get(RobotGlobalsId)\r
+ suspend fun set(instance: RobotGlobals) = Table.put(instance)\r
+ suspend fun delete() = Table.del(RobotGlobalsId)\r
+ \r
+ override suspend fun initialize() = Unit\r
+ }\r
+}\r
+\r
+private fun RobotGlobals.plusThread(threadId: RobotThreadId) = copy(\r
+ ongoingThreadIds = ongoingThreadIds + threadId\r
+)\r
+\r
+private fun RobotGlobals.minusThread(threadId: RobotThreadId) = copy(\r
+ ongoingThreadIds = ongoingThreadIds - threadId\r
+)\r
+\r
+enum class RobotServiceStatus {\r
+ NOT_CONFIGURED,\r
+ LOADING,\r
+ FAILED,\r
+ READY,\r
+}\r
+\r
+class RobotService(\r
+ private val config: OpenAiConfig,\r
+) {\r
+ private val robotClient = RobotClient(\r
+ HttpClient(Java) {\r
+ defaultRequest {\r
+ header(HttpHeaders.Authorization, "Bearer ${config.token}")\r
+ header("OpenAI-Organization", config.orgId)\r
+ config.project?.let { header("OpenAI-Project", it) }\r
+ header("OpenAI-Beta", "assistants=v2")\r
+ }\r
+ \r
+ install(ContentNegotiation) {\r
+ json(JsonRobotCodec)\r
+ }\r
+ \r
+ Logging {\r
+ level = LogLevel.INFO\r
+ sanitizeHeader("<OPENAI TOKEN>") { it == HttpHeaders.Authorization }\r
+ }\r
+ \r
+ install(HttpRequestRetry) {\r
+ retryOnExceptionOrServerErrors(5)\r
+ delayMillis { retry ->\r
+ (1 shl (retry - 1)) * 1000L + Random.nextLong(250L, 750L)\r
+ }\r
+ }\r
+ \r
+ expectSuccess = true\r
+ \r
+ install(RobotRateLimiter)\r
+ }\r
+ )\r
+ \r
+ private suspend fun createThread(): RobotThreadId {\r
+ return robotClient.createThread(RobotCreateThreadRequest()).id.also { threadId ->\r
+ (RobotGlobals.get() ?: RobotGlobals()).plusThread(threadId).save()\r
+ }\r
+ }\r
+ \r
+ private suspend fun deleteThread(threadId: RobotThreadId) {\r
+ robotClient.deleteThread(threadId)\r
+ (RobotGlobals.get() ?: RobotGlobals()).minusThread(threadId).save()\r
+ }\r
+ \r
+ private suspend fun RobotGlobals.gcOldThreads(): RobotGlobals {\r
+ for (threadId in ongoingThreadIds)\r
+ try {\r
+ robotClient.deleteThread(threadId)\r
+ } catch (ex: ClientRequestException) {\r
+ logger.warn("Unable to delete thread at ID $threadId", ex)\r
+ }\r
+ return copy(ongoingThreadIds = emptySet())\r
+ }\r
+ \r
+ private suspend fun updateFiles(prevGlobals: RobotGlobals?, onNewFileId: (suspend (RobotFileId) -> Unit)? = null): RobotGlobals {\r
+ val robotGlobals = prevGlobals ?: RobotGlobals()\r
+ \r
+ val fileIdMap = buildMap<String, RobotFileId> {\r
+ putAll(robotGlobals.fileIdMap)\r
+ \r
+ val factbooks = robotGlobals.lastFileUpload?.let {\r
+ RobotFactbookLoader.loadAllFactbooksSince(it)\r
+ } ?: RobotFactbookLoader.loadAllFactbooks()\r
+ \r
+ for ((name, text) in factbooks) {\r
+ remove(name)?.let { oldId ->\r
+ try {\r
+ robotClient.deleteFile(oldId)\r
+ } catch (ex: ClientRequestException) {\r
+ logger.warn("Unable to delete file $name at ID $oldId", ex)\r
+ }\r
+ }\r
+ \r
+ val newId = robotClient.uploadFile(\r
+ "assistants",\r
+ FileUpload(\r
+ text.toByteArray(),\r
+ ContentType.Text.Plain.withCharset(Charsets.UTF_8),\r
+ name.toOpenAiName()\r
+ )\r
+ ).id\r
+ \r
+ this[name] = newId\r
+ onNewFileId?.invoke(newId)\r
+ \r
+ logger.info("Factbook $name has been uploaded")\r
+ }\r
+ }\r
+ \r
+ return robotGlobals.copy(lastFileUpload = Instant.now(), fileIdMap = fileIdMap).save()\r
+ }\r
+ \r
+ suspend fun initialize() {\r
+ var robotGlobals = updateFiles(RobotGlobals.get()?.gcOldThreads())\r
+ \r
+ val vectorStoreId = robotGlobals.vectorStoreId ?: robotClient.createVectorStore(\r
+ RobotCreateVectorStoreRequest(\r
+ name = "lore_documents",\r
+ fileIds = robotGlobals.fileIdMap.values.toList(),\r
+ )\r
+ ).id.also { vsId ->\r
+ robotGlobals = robotGlobals.copy(vectorStoreId = vsId).save()\r
+ }\r
+ \r
+ logger.info("Vector store has been created")\r
+ \r
+ poll {\r
+ robotClient.getVectorStore(vectorStoreId).status == "completed"\r
+ }\r
+ \r
+ logger.info("Vector store creation is complete")\r
+ \r
+ if (robotGlobals.assistantId == null)\r
+ robotGlobals = robotGlobals.copy(\r
+ assistantId = robotClient.createAssistant(\r
+ RobotCreateAssistantRequest(\r
+ model = config.assistantModel,\r
+ name = config.assistantName,\r
+ instructions = config.assistantInstructions,\r
+ tools = listOf(\r
+ RobotCreateAssistantRequestTool("file_search")\r
+ ),\r
+ toolResources = RobotCreateAssistantRequestToolResources(\r
+ fileSearch = RobotCreateAssistantRequestFileSearchResources(\r
+ vectorStoreIds = listOf(vectorStoreId)\r
+ )\r
+ ),\r
+ temperature = config.assistantTemperature\r
+ )\r
+ ).id\r
+ ).save()\r
+ \r
+ logger.info("Assistant has been created")\r
+ }\r
+ \r
+ suspend fun performMaintenance() {\r
+ var robotGlobals = RobotGlobals.get() ?: RobotGlobals()\r
+ \r
+ val vectorStoreId = robotGlobals.vectorStoreId ?: robotClient.createVectorStore(\r
+ RobotCreateVectorStoreRequest(\r
+ name = "lore_documents",\r
+ fileIds = robotGlobals.fileIdMap.values.toList(),\r
+ )\r
+ ).id.also { vsId ->\r
+ robotGlobals = robotGlobals.copy(vectorStoreId = vsId).save()\r
+ }\r
+ \r
+ updateFiles(robotGlobals) { fileId ->\r
+ robotClient.addFileToVectorStore(vectorStoreId, fileId)\r
+ }\r
+ \r
+ logger.info("Vector store has been updated")\r
+ \r
+ poll {\r
+ robotClient.getVectorStore(vectorStoreId).fileCounts.inProgress == 0\r
+ }\r
+ \r
+ logger.info("Vector store update is complete")\r
+ }\r
+ \r
+ suspend fun reset() {\r
+ RobotGlobals.get()?.gcOldThreads()?.copy(\r
+ lastFileUpload = null,\r
+ fileIdMap = emptyMap(),\r
+ vectorStoreId = null,\r
+ assistantId = null,\r
+ )?.save()\r
+ \r
+ while (true) {\r
+ val assistants = robotClient.listAssistants().data\r
+ if (assistants.isEmpty()) break\r
+ \r
+ assistants.map { it.id }.forEach {\r
+ robotClient.deleteAssistant(it)\r
+ }\r
+ }\r
+ \r
+ while (true) {\r
+ val vectorStores = robotClient.listVectorStores().data\r
+ if (vectorStores.isEmpty()) break\r
+ \r
+ vectorStores.map { it.id }.forEach {\r
+ robotClient.deleteVectorStore(it)\r
+ }\r
+ }\r
+ \r
+ robotClient.listFiles().data.map { it.id }.forEach {\r
+ robotClient.deleteFile(it)\r
+ }\r
+ \r
+ initialize()\r
+ }\r
+ \r
+ inner class Conversation(private val nationId: Id<NationData>) {\r
+ private var assistantId: RobotAssistantId? = null\r
+ private var threadId: RobotThreadId? = null\r
+ \r
+ suspend fun send(userMessage: String): Flow<RobotConversationMessage> {\r
+ val assistant = assistantId ?: pollValue { RobotGlobals.get()?.assistantId }\r
+ .also { assistantId = it }\r
+ \r
+ val thread = threadId ?: createThread().also { threadId = it }\r
+ \r
+ val messages = listOf(\r
+ RobotCreateThreadRequestMessage(\r
+ role = "user",\r
+ content = userMessage\r
+ )\r
+ )\r
+ \r
+ val tokenTracker = ConversationMessageTokenTracker()\r
+ \r
+ return flow {\r
+ emit(RobotConversationMessage.User(userMessage))\r
+ \r
+ val annotationTargets = mutableListOf<Deferred<String>>()\r
+ val collectionScope = CoroutineScope(currentCoroutineContext())\r
+ \r
+ robotClient.createRun(thread, assistant, messages)\r
+ .filter { it.event == "thread.message.delta" }\r
+ .mapNotNull { it.data }\r
+ .map { JsonRobotCodec.decodeFromString(RobotMessageDelta.serializer(), it) }\r
+ .collect { eventData ->\r
+ val annotationTexts = eventData.delta.content.flatMap { it.text.annotations }.map { annotation ->\r
+ val annotationIndex = annotationTargets.size\r
+ annotationTargets.add(collectionScope.async {\r
+ val fileName = robotClient.getFile(annotation.fileCitation.fileId).filename.fromOpenAiName()\r
+ val fileText = annotation.fileCitation.quote.let { if (it.isNotBlank()) ": $it" else it }\r
+ "$MainDomainName/lore/$fileName$fileText"\r
+ })\r
+ annotation.text to " [${annotationIndex + 1}]"\r
+ }\r
+ \r
+ val contents = eventData.delta.content.joinToString(separator = "") { textContent ->\r
+ textContent.text.value\r
+ }\r
+ \r
+ val replacedContents = annotationTexts.fold(contents) { text, (replace, replaceWith) ->\r
+ text.replace(replace, replaceWith)\r
+ }\r
+ \r
+ emit(RobotConversationMessage.Robot(replacedContents))\r
+ }\r
+ \r
+ emit(RobotConversationMessage.Cite(annotationTargets.awaitAll()))\r
+ \r
+ emit(RobotConversationMessage.Ready)\r
+ }.onEach { message ->\r
+ tokenTracker.addMessage(message)\r
+ }.onCompletion { _ ->\r
+ RobotUser.addTokens(nationId, tokenTracker.calculateTokens())\r
+ }\r
+ }\r
+ \r
+ suspend fun isExhausted(): Boolean {\r
+ val usedTokens = RobotUser.getTokens(nationId)\r
+ val tokenLimit = RobotUser.getMaxTokens(nationId)\r
+ return usedTokens >= tokenLimit\r
+ }\r
+ \r
+ suspend fun close() {\r
+ threadId?.let { deleteThread(it) }\r
+ }\r
+ }\r
+ \r
+ companion object {\r
+ private val logger: Logger = LoggerFactory.getLogger(RobotService::class.java)\r
+ \r
+ private val maintenanceScope = CoroutineScope(SupervisorJob() + CoroutineName("robot-service-maintenance"))\r
+ \r
+ private val instanceHolder by lazy {\r
+ CoroutineScope(CoroutineName("robot-service-initialization")).async {\r
+ Configuration.Current.openAi?.let(::RobotService)?.apply {\r
+ initialize()\r
+ }\r
+ }\r
+ }\r
+ \r
+ var status: RobotServiceStatus = if (Configuration.Current.openAi != null) RobotServiceStatus.LOADING else RobotServiceStatus.NOT_CONFIGURED\r
+ private set\r
+ \r
+ suspend fun getInstance() = try {\r
+ instanceHolder.await()\r
+ } catch (ex: Exception) {\r
+ null\r
+ }\r
+ \r
+ fun initialize() {\r
+ instanceHolder.invokeOnCompletion { ex ->\r
+ status = if (ex != null) {\r
+ logger.error("RobotService failed to initialize", ex)\r
+ RobotServiceStatus.FAILED\r
+ } else {\r
+ logger.info("RobotService successfully initialized")\r
+ RobotServiceStatus.READY\r
+ }\r
+ }\r
+ \r
+ maintenanceScope.launch {\r
+ getInstance()?.let { instance ->\r
+ while (true) {\r
+ delay(30.minutes)\r
+ \r
+ launch(SupervisorJob(currentCoroutineContext().job)) {\r
+ instance.performMaintenance()\r
+ }\r
+ }\r
+ }\r
+ }\r
+ }\r
+ }\r
+}\r
+\r
+@Serializable\r
+sealed class RobotConversationMessage {\r
+ @Serializable\r
+ @SerialName("ready")\r
+ data object Ready : RobotConversationMessage()\r
+ \r
+ @Serializable\r
+ @SerialName("user")\r
+ data class User(val text: String) : RobotConversationMessage()\r
+ \r
+ @Serializable\r
+ @SerialName("robot")\r
+ data class Robot(val text: String) : RobotConversationMessage()\r
+ \r
+ @Serializable\r
+ @SerialName("cite")\r
+ data class Cite(val urls: List<String>) : RobotConversationMessage()\r
+}\r