Add config properties for OpenAI GPT model, assistant name, instructions, and temperature
authorLanius Trolling <lanius@laniustrolling.dev>
Tue, 23 Jul 2024 17:03:32 +0000 (13:03 -0400)
committerLanius Trolling <lanius@laniustrolling.dev>
Tue, 23 Jul 2024 17:03:32 +0000 (13:03 -0400)
src/jvmMain/kotlin/info/mechyrdia/Configuration.kt
src/jvmMain/kotlin/info/mechyrdia/robot/RobotService.kt

index 2e3eba0bd39139eafb0e50108d947b96673645cf..ff3be8e4eb821cac1950dff621a94744f1481f89 100644 (file)
@@ -1,62 +1,66 @@
-package info.mechyrdia
-
-import info.mechyrdia.data.Id
-import info.mechyrdia.data.NationData
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-import java.io.File
-
-@Serializable
-sealed class FileStorageConfig {
-       @Serializable
-       @SerialName("flat")
-       data class Flat(val baseDir: String) : FileStorageConfig()
-       
-       @Serializable
-       @SerialName("gridFS")
-       data object GridFs : FileStorageConfig()
-}
-
-@Serializable
-data class OpenAiConfig(
-       val token: String,
-       val orgId: String,
-       val project: String? = null,
-)
-
-@Serializable
-data class Configuration(
-       val host: String = "127.0.0.1",
-       val port: Int = 8080,
-       
-       val isDevMode: Boolean = false,
-       
-       val storage: FileStorageConfig = FileStorageConfig.Flat(".."),
-       
-       val dbName: String = "nslore",
-       val dbConn: String = "mongodb://localhost:27017",
-       
-       val ownerNation: String = "mechyrdia",
-       val emergencyPassword: String? = null,
-       
-       val openAi: OpenAiConfig? = null,
-) {
-       companion object {
-               val Current: Configuration by lazy {
-                       val file = File(System.getProperty("info.mechyrdia.configpath", "./config.json"))
-                       if (!file.isFile) {
-                               if (file.exists())
-                                       file.deleteRecursively()
-                               
-                               file.writeText(JsonFileCodec.encodeToString(serializer(), Configuration()), Charsets.UTF_8)
-                       }
-                       
-                       JsonFileCodec.decodeFromString(serializer(), file.readText(Charsets.UTF_8))
-               }
-       }
-}
-
-val OwnerNationId: Id<NationData>
-       get() = Id(Configuration.Current.ownerNation)
-
-const val MainDomainName = "https://mechyrdia.info"
+package info.mechyrdia\r
+\r
+import info.mechyrdia.data.Id\r
+import info.mechyrdia.data.NationData\r
+import kotlinx.serialization.SerialName\r
+import kotlinx.serialization.Serializable\r
+import java.io.File\r
+\r
+@Serializable\r
+sealed class FileStorageConfig {\r
+       @Serializable\r
+       @SerialName("flat")\r
+       data class Flat(val baseDir: String) : FileStorageConfig()\r
+       \r
+       @Serializable\r
+       @SerialName("gridFS")\r
+       data object GridFs : FileStorageConfig()\r
+}\r
+\r
+@Serializable\r
+data class OpenAiConfig(\r
+       val token: String,\r
+       val orgId: String,\r
+       val project: String? = null,\r
+       val assistantModel: String = "gpt-4o-mini",\r
+       val assistantName: String = "Natural-language Universal Knowledge Engine",\r
+       val assistantInstructions: String = "You are a helpful interactive encyclopedia, able to answer questions with information from the provided files",\r
+       val assistantTemperature: Double = 1.0,\r
+)\r
+\r
+@Serializable\r
+data class Configuration(\r
+       val host: String = "127.0.0.1",\r
+       val port: Int = 8080,\r
+       \r
+       val isDevMode: Boolean = false,\r
+       \r
+       val storage: FileStorageConfig = FileStorageConfig.Flat(".."),\r
+       \r
+       val dbName: String = "nslore",\r
+       val dbConn: String = "mongodb://localhost:27017",\r
+       \r
+       val ownerNation: String = "mechyrdia",\r
+       val emergencyPassword: String? = null,\r
+       \r
+       val openAi: OpenAiConfig? = null,\r
+) {\r
+       companion object {\r
+               val Current: Configuration by lazy {\r
+                       val file = File(System.getProperty("info.mechyrdia.configpath", "./config.json"))\r
+                       if (!file.isFile) {\r
+                               if (file.exists())\r
+                                       file.deleteRecursively()\r
+                               \r
+                               file.writeText(JsonFileCodec.encodeToString(serializer(), Configuration()), Charsets.UTF_8)\r
+                       }\r
+                       \r
+                       JsonFileCodec.decodeFromString(serializer(), file.readText(Charsets.UTF_8))\r
+               }\r
+       }\r
+}\r
+\r
+val OwnerNationId: Id<NationData>\r
+       get() = Id(Configuration.Current.ownerNation)\r
+\r
+const val MainDomainName = "https://mechyrdia.info"\r
index 2afa2aeb0fcb6cb9d525b0bf0c345de19b3877e5..486c87d582e07b45a6323a85ed796585ab741cca 100644 (file)
-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