From af7577c65b0dbf66620cadf01e5db97dfe29bf34 Mon Sep 17 00:00:00 2001 From: xds Date: Mon, 7 Apr 2025 18:26:23 +0300 Subject: [PATCH] + nlp --- build.gradle.kts | 1 + .../luminic/budgerapp/BudgerAppApplication.kt | 5 +- .../luminic/budgerapp/configs/CommonConfig.kt | 11 +- .../budgerapp/controllers/SpaceController.kt | 67 +++++++++- .../budgerapp/models/CategoryPrediction.kt | 4 + .../luminic/budgerapp/services/BotService.kt | 118 +++++++++++------- .../budgerapp/services/CategoryService.kt | 29 +++-- .../budgerapp/services/FinancialService.kt | 16 ++- .../luminic/budgerapp/services/NLPService.kt | 30 +++++ .../budgerapp/services/SpaceService.kt | 3 +- src/main/resources/application-dev.properties | 1 + .../resources/application-prod.properties | 2 +- src/main/resources/application.properties | 4 +- 13 files changed, 229 insertions(+), 62 deletions(-) create mode 100644 src/main/kotlin/space/luminic/budgerapp/models/CategoryPrediction.kt create mode 100644 src/main/kotlin/space/luminic/budgerapp/services/NLPService.kt diff --git a/build.gradle.kts b/build.gradle.kts index 90d887a..44d8313 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -58,6 +58,7 @@ dependencies { implementation("org.telegram:telegrambots:6.9.7.1") implementation("org.telegram:telegrambots-spring-boot-starter:6.9.7.1") + implementation("com.opencsv:opencsv:5.10") diff --git a/src/main/kotlin/space/luminic/budgerapp/BudgerAppApplication.kt b/src/main/kotlin/space/luminic/budgerapp/BudgerAppApplication.kt index 8d187c7..3703e43 100644 --- a/src/main/kotlin/space/luminic/budgerapp/BudgerAppApplication.kt +++ b/src/main/kotlin/space/luminic/budgerapp/BudgerAppApplication.kt @@ -2,12 +2,14 @@ package space.luminic.budgerapp import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.runApplication import org.springframework.cache.annotation.EnableCaching import org.springframework.data.mongodb.repository.config.EnableMongoRepositories import org.springframework.scheduling.annotation.EnableAsync import org.springframework.scheduling.annotation.EnableScheduling +import space.luminic.budgerapp.configs.NLPConfig import space.luminic.budgerapp.configs.TelegramBotProperties import java.util.* @@ -15,7 +17,8 @@ import java.util.* @EnableCaching @EnableAsync @EnableScheduling -@EnableConfigurationProperties(TelegramBotProperties::class) +//@EnableConfigurationProperties([TelegramBotProperties::class,) +@ConfigurationPropertiesScan(basePackages = ["space.luminic.budgerapp"]) @EnableMongoRepositories(basePackages = ["space.luminic.budgerapp.repos"]) class BudgerAppApplication diff --git a/src/main/kotlin/space/luminic/budgerapp/configs/CommonConfig.kt b/src/main/kotlin/space/luminic/budgerapp/configs/CommonConfig.kt index ff0b04e..c8fd9b3 100644 --- a/src/main/kotlin/space/luminic/budgerapp/configs/CommonConfig.kt +++ b/src/main/kotlin/space/luminic/budgerapp/configs/CommonConfig.kt @@ -1,12 +1,19 @@ package space.luminic.budgerapp.configs +import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -//@Configuration + //class CommonConfig { // @Bean // fun httpTraceRepository(): HttpTraceRepository { // return InMemoryHttpTraceRepository() // } -//} \ No newline at end of file +//} + + +@ConfigurationProperties(prefix = "nlp") +data class NLPConfig( + val address: String, +) \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/budgerapp/controllers/SpaceController.kt b/src/main/kotlin/space/luminic/budgerapp/controllers/SpaceController.kt index 245724e..cd41f70 100644 --- a/src/main/kotlin/space/luminic/budgerapp/controllers/SpaceController.kt +++ b/src/main/kotlin/space/luminic/budgerapp/controllers/SpaceController.kt @@ -1,18 +1,25 @@ package space.luminic.budgerapp.controllers +import com.opencsv.CSVWriter import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.apache.commons.io.IOUtils.writer import org.bson.Document import org.slf4j.LoggerFactory +import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus +import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* import space.luminic.budgerapp.controllers.BudgetController.LimitValue import space.luminic.budgerapp.controllers.dtos.BudgetCreationDTO import space.luminic.budgerapp.models.* import space.luminic.budgerapp.services.* +import java.io.ByteArrayOutputStream +import java.io.OutputStreamWriter import java.time.LocalDate + @RestController @RequestMapping("/spaces") class SpaceController( @@ -172,6 +179,63 @@ class SpaceController( } } + @GetMapping("/{spaceId}/transactions/csv") + suspend fun getTransactionsCSV( + @PathVariable spaceId: String, + @RequestParam(value = "transaction_type") transactionType: String? = null, + @RequestParam(value = "category_type") categoryType: String? = null, + @RequestParam(value = "user_id") userId: String? = null, + @RequestParam(value = "is_child") isChild: Boolean? = null, + @RequestParam(value = "limit") limit: Int = 20000, + @RequestParam(value = "offset") offset: Int = 0 + ): ResponseEntity { + try { + val bos = ByteArrayOutputStream() + val writer = CSVWriter(OutputStreamWriter(bos)) + val CSVHeaders = arrayOf("id", "name", "category") + writer.writeNext(CSVHeaders) + financialService.getTransactions( + spaceId = spaceId, + transactionType = transactionType, + categoryType = categoryType, + userId = userId, + isChild = isChild, + limit = limit, + offset = offset + ).awaitSingle().map { + val data = arrayOf(it.id, it.comment, it.category.name) + writer.writeNext(data) + } + writer.close() + + val csvData = bos.toByteArray() + val headers = HttpHeaders() + headers.contentType = MediaType.parseMediaType("text/csv") + headers.setContentDispositionFormData("attachment", "pojos.csv") + + return ResponseEntity(csvData, headers, HttpStatus.OK) + + } catch (e: Exception) { + e.printStackTrace() + return ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR) + } + } + + @GetMapping("/{spaceId}/category-predict") + suspend fun getTransactionCategoryPredict( + @PathVariable spaceId: String, + @RequestParam comment: String + ): List { + val user = authService.getSecurityUser() + spaceService.isValidRequest(spaceId, user) + return categoryService.getCategories( + "67af3c0f652da946a7dd9931", + "EXPENSE", + sortBy = "name", + direction = "ASC", + predict = comment + ) + } @GetMapping("/{spaceId}/transactions/{id}") suspend fun getTransaction( @@ -181,6 +245,7 @@ class SpaceController( return financialService.getTransactionById(id) } + @PostMapping("/{spaceId}/transactions") suspend fun createTransaction(@PathVariable spaceId: String, @RequestBody transaction: Transaction): Transaction { val user = authService.getSecurityUser() @@ -220,7 +285,7 @@ class SpaceController( ): List { val user = authService.getSecurityUser() spaceService.isValidRequest(spaceId, user) - return categoryService.getCategories(spaceId, type, sortBy, direction).awaitSingleOrNull().orEmpty() + return categoryService.getCategories(spaceId, type, sortBy, direction) } @GetMapping("/{spaceId}/categories/types") diff --git a/src/main/kotlin/space/luminic/budgerapp/models/CategoryPrediction.kt b/src/main/kotlin/space/luminic/budgerapp/models/CategoryPrediction.kt new file mode 100644 index 0000000..2017388 --- /dev/null +++ b/src/main/kotlin/space/luminic/budgerapp/models/CategoryPrediction.kt @@ -0,0 +1,4 @@ +package space.luminic.budgerapp.models + +data class CategoryPrediction(val category: String, val weight: Double) { +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/budgerapp/services/BotService.kt b/src/main/kotlin/space/luminic/budgerapp/services/BotService.kt index f0234af..f08519b 100644 --- a/src/main/kotlin/space/luminic/budgerapp/services/BotService.kt +++ b/src/main/kotlin/space/luminic/budgerapp/services/BotService.kt @@ -14,6 +14,7 @@ import org.springframework.stereotype.Service import org.telegram.telegrambots.bots.TelegramLongPollingBot import org.telegram.telegrambots.meta.api.methods.send.SendMessage import org.telegram.telegrambots.meta.api.methods.updatingmessages.DeleteMessage +import org.telegram.telegrambots.meta.api.methods.updatingmessages.EditMessageReplyMarkup import org.telegram.telegrambots.meta.api.methods.updatingmessages.EditMessageText import org.telegram.telegrambots.meta.api.objects.Update import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup @@ -35,9 +36,76 @@ class BotService( private val categoriesService: CategoryService, private val financialService: FinancialService, private val spaceService: SpaceService, + private val nlpService: NLPService ) : TelegramLongPollingBot(telegramBotProperties.token) { private val logger = LoggerFactory.getLogger(javaClass) + private suspend fun constructCategoriesButtons( + nlp: Boolean, + text: String? = null, + chatId: Long + ): InlineKeyboardMarkup { + val categories = + categoriesService.getCategories( + "67af3c0f652da946a7dd9931", + "EXPENSE", + sortBy = "name", + direction = "ASC" + ) + + val keyboard = InlineKeyboardMarkup() + val buttonLines = mutableListOf>() + val filteredCategories = mutableListOf() + if (nlp) { + if (text.isNullOrBlank()) { + throw TelegramBotException("Текст не может быть пустым", chatId) + } + val predictedCategories = nlpService.predictCategory(text, 0) + + + for (category in predictedCategories) { + filteredCategories.add(categories.first { it.name == category.category }) + } + } else { + filteredCategories.addAll(categories) + } + filteredCategories.map { category -> + val btn = InlineKeyboardButton.builder().text("${category.icon} ${category.name}") + .callbackData("category_${category.id}").build() + + if (category.name.length >= 15) { + // Если текст длинный, создаём отдельную строку для кнопки + buttonLines.add(mutableListOf(btn)) + } else { + var isAdded = false + + // Пытаемся добавить кнопку в существующую строку + for (line in buttonLines) { + if (line.size < 2 && (line.isEmpty() || line[0].text.length < 14)) { + line.add(btn) + isAdded = true + break + } + } + + // Если не нашли подходящую строку, создаём новую + if (!isAdded) { + buttonLines.add(mutableListOf(btn)) + } else { + + } + } + } + val allCatsBtn = InlineKeyboardButton.builder().text("Все категории").callbackData("all_cats").build() + val backButton = InlineKeyboardButton.builder().text("Отмена").callbackData("cancel").build() + + buttonLines.add(mutableListOf(allCatsBtn)) + buttonLines.add(mutableListOf(backButton)) + keyboard.keyboard = buttonLines + return keyboard + } + + override fun getBotUsername(): String { return telegramBotProperties.username } @@ -85,6 +153,12 @@ class BotService( val deleteMsg = DeleteMessage(chatId, update.callbackQuery.message.messageId) execute(deleteMsg) sendMessage(chatId, "Введите сумму и комментарий когда будете готовы.") + } else if (update.callbackQuery.data == "all_cats") { + val editMessageReplyMarkup = EditMessageReplyMarkup() + editMessageReplyMarkup.chatId = chatId + editMessageReplyMarkup.messageId = update.callbackQuery.message.messageId + editMessageReplyMarkup.replyMarkup = constructCategoriesButtons(false, chatId = chatId.toLong()) + execute(editMessageReplyMarkup) } } } @@ -149,53 +223,12 @@ class BotService( comment += "$word " } - val categories = - categoriesService.getCategories( - "67af3c0f652da946a7dd9931", - "EXPENSE", - sortBy = "name", - direction = "ASC" - ) - .awaitSingle() - val keyboard = InlineKeyboardMarkup() - val buttonLines = mutableListOf>() - categories.map { category -> - val btn = InlineKeyboardButton.builder().text("${category.icon} ${category.name}") - .callbackData("category_${category.id}").build() - - if (category.name.length >= 15) { - // Если текст длинный, создаём отдельную строку для кнопки - buttonLines.add(mutableListOf(btn)) - } else { - var isAdded = false - - // Пытаемся добавить кнопку в существующую строку - for (line in buttonLines) { - if (line.size < 2 && (line.isEmpty() || line[0].text.length < 14)) { - line.add(btn) - isAdded = true - break - } - } - - // Если не нашли подходящую строку, создаём новую - if (!isAdded) { - buttonLines.add(mutableListOf(btn)) - } else { - - } - } - } - val backButton = InlineKeyboardButton.builder().text("Отмена").callbackData("cancel").build() - - buttonLines.add(mutableListOf(backButton)) - keyboard.keyboard = buttonLines val message = SendMessage() message.chatId = chatId val msg = "Выберите категорию" message.text = msg - message.replyMarkup = keyboard + message.replyMarkup = constructCategoriesButtons(true, text, chatId.toLong()) val userState = BotUserState(user = user) val chatData = userState.data.find { it.chatId == chatId } ?: ChatData(chatId, state = BotStates.WAIT_CATEGORY) @@ -229,7 +262,6 @@ class BotService( sortBy = "name", direction = "ASC" ) - .awaitSingle() .first { it.id == update.callbackQuery.data.split("_")[1] } val space = spaceService.getSpace("67af3c0f652da946a7dd9931") val instantType = financialService.getTransactionTypes().first { it.code == "INSTANT" } diff --git a/src/main/kotlin/space/luminic/budgerapp/services/CategoryService.kt b/src/main/kotlin/space/luminic/budgerapp/services/CategoryService.kt index 5fa98de..d2dfdab 100644 --- a/src/main/kotlin/space/luminic/budgerapp/services/CategoryService.kt +++ b/src/main/kotlin/space/luminic/budgerapp/services/CategoryService.kt @@ -3,6 +3,7 @@ package space.luminic.budgerapp.services import kotlinx.coroutines.reactive.awaitFirstOrNull import kotlinx.coroutines.reactive.awaitSingle +import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingleOrNull import org.bson.Document import org.bson.types.ObjectId @@ -28,8 +29,8 @@ class CategoryService( private val mongoTemplate: ReactiveMongoTemplate, private val categoryMapper: CategoryMapper, private val budgetRepo: BudgetRepo, - - ) { + private val nlpService: NLPService +) { private val logger = LoggerFactory.getLogger(javaClass) @@ -67,14 +68,15 @@ class CategoryService( }.awaitFirstOrNull() ?: throw NotFoundException("Category not found") } - - fun getCategories( + @Cacheable(cacheNames = ["categories"]) + suspend fun getCategories( spaceId: String, type: String? = null, sortBy: String, direction: String, - tagCode: String? = null - ): Mono> { + tagCode: String? = null, + predict: String? = null + ): MutableList { val lookupSpaces = lookup("spaces", "space.\$id", "_id", "spaceDetails") val unwindSpace = unwind("spaceDetails") val matchCriteria = mutableListOf() @@ -99,7 +101,7 @@ class CategoryService( ).filterNotNull() val aggregation = newAggregation(aggregationBuilder) - return mongoTemplate.aggregate( + val categories = mongoTemplate.aggregate( aggregation, "categories", Document::class.java ) .collectList() // Преобразуем Flux в Mono> @@ -107,9 +109,20 @@ class CategoryService( docs.map { doc -> categoryMapper.fromDocument(doc) } - } + }.awaitSingle().toMutableList() + + val predictedCategories = mutableListOf() + if (!predict.isNullOrBlank()) { + predictedCategories.addAll(nlpService.predictCategory(predict, 1)) + } + val filteredCategories = mutableListOf() + for (category in predictedCategories) { + categories.find { it.name == category.category }?.let { filteredCategories.add(it) } + } + return if (filteredCategories.isEmpty()) categories else filteredCategories } + @Cacheable("categoryTypes") fun getCategoryTypes(): List { val types = mutableListOf() diff --git a/src/main/kotlin/space/luminic/budgerapp/services/FinancialService.kt b/src/main/kotlin/space/luminic/budgerapp/services/FinancialService.kt index 8046f99..e6cab11 100644 --- a/src/main/kotlin/space/luminic/budgerapp/services/FinancialService.kt +++ b/src/main/kotlin/space/luminic/budgerapp/services/FinancialService.kt @@ -47,7 +47,7 @@ class FinancialService( private val categoryRepo: CategoryRepo, val transactionsMapper: TransactionsMapper, val budgetMapper: BudgetMapper, - private val subscriptionService: SubscriptionService + private val subscriptionService: SubscriptionService, ) { private val logger = LoggerFactory.getLogger(FinancialService::class.java) @@ -57,8 +57,14 @@ class FinancialService( transaction.space!!.id!!, budgetId = null, transaction.date, transaction.date ) if (transaction.category.type.code == "EXPENSE") { - val budgetCategory = budget.categories.firstOrNull { it.category.id == transaction.category.id } - ?: throw NotFoundException("Budget category not found in the budget") + var budgetCategory = budget.categories.firstOrNull { it.category.id == transaction.category.id } + + if (budgetCategory == null) { + budgetCategory = BudgetCategory(0.0, 0.0, 0.0, transaction.category) + budget.categories.add(budgetCategory) + budgetRepo.save(budget).awaitSingle() + + } if (transaction.category.type.code == "INCOME") { budgetRepo.save(budget).awaitSingle() } @@ -845,6 +851,7 @@ class FinancialService( } + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) suspend fun createTransaction(space: Space, transaction: Transaction, user: User? = null): Transaction { @@ -930,12 +937,15 @@ class FinancialService( "main" -> { "Пользователь ${author.username} изменил $transactionType транзакцию:\n$sb" } + "done_true" -> { "Пользователь ${author.username} выполнил ${transaction.comment} с суммой ${transaction.amount.toInt()}" } + "done_false" -> { "Пользователь ${author.username} отменил выполнение ${transaction.comment} с суммой ${transaction.amount.toInt()}" } + else -> "Изменения не обнаружены, но что то точно изменилось" } diff --git a/src/main/kotlin/space/luminic/budgerapp/services/NLPService.kt b/src/main/kotlin/space/luminic/budgerapp/services/NLPService.kt new file mode 100644 index 0000000..3805aa8 --- /dev/null +++ b/src/main/kotlin/space/luminic/budgerapp/services/NLPService.kt @@ -0,0 +1,30 @@ +package space.luminic.budgerapp.services + +import kotlinx.coroutines.reactive.awaitSingle +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.WebClient +import space.luminic.budgerapp.configs.NLPConfig +import space.luminic.budgerapp.models.CategoryPrediction + +@Service +class NLPService( + private val nlpConfig: NLPConfig, + private val webClient: WebClient = WebClient.builder() + .baseUrl(nlpConfig.address) +// .defaultHeader("Authorization", "Bearer YOUR_API_KEY") + .build(), +) { + + + + + suspend fun predictCategory(category: String, cloud: Int): List { + val response = webClient.get().uri("/predict?req=$category&cloud=$cloud") + .retrieve() + .bodyToFlux(CategoryPrediction::class.java) + .collectList() + .awaitSingle() + return response + } + +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/budgerapp/services/SpaceService.kt b/src/main/kotlin/space/luminic/budgerapp/services/SpaceService.kt index cc24835..7eb63fa 100644 --- a/src/main/kotlin/space/luminic/budgerapp/services/SpaceService.kt +++ b/src/main/kotlin/space/luminic/budgerapp/services/SpaceService.kt @@ -140,7 +140,7 @@ class SpaceService( launch { val categories = - categoryService.getCategories(objectId.toString(), null, "name", "ASC").awaitFirstOrNull().orEmpty() + categoryService.getCategories(objectId.toString(), null, "name", "ASC") categoryRepo.deleteAll(categories).awaitFirstOrNull() } @@ -280,7 +280,6 @@ class SpaceService( val existedTag = findTag(space, tagCode) ?: throw NoSuchElementException("Tag with code $tagCode not found") val categoriesWithTag = categoryService.getCategories(space.id!!, sortBy = "name", direction = "ASC", tagCode = existedTag.code) - .awaitSingleOrNull().orEmpty() categoriesWithTag.map { cat -> cat.tags.removeIf { it.code == tagCode } // Изменяем список тегов cat diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index c1f8b86..89d2f5b 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -6,3 +6,4 @@ spring.data.mongodb.uri=mongodb://budger-app:BA1q2w3e4r!@luminic.space:27017/bud management.endpoints.web.exposure.include=* management.endpoint.health.show-details=always telegram.bot.token=6972242509:AAGyXuL3T-BNE4XMoo_qvtaYxw_SuiS_dDs +nlp.address=http://127.0.0.1:8000 \ No newline at end of file diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 1db784f..02ffd09 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -17,4 +17,4 @@ logging.level.org.springframework.data.mongodb.code = DEBUG telegram.bot.token = 6662300972:AAFXjk_h0AUCy4bORC12UcdXbYnh2QSVKAY - +nlp.address=https://nlp.luminic.space diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 894c2e4..052a488 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -4,11 +4,13 @@ server.port=8082 #server.servlet.context-path=/api spring.webflux.base-path=/api -spring.profiles.active=prod +spring.profiles.active=dev spring.main.web-application-type=reactive + + logging.level.org.springframework.web=INFO logging.level.org.springframework.data = INFO logging.level.org.springframework.data.mongodb.core.ReactiveMongoTemplate=INFO