From 7239b84a8642532e88ee5877943b6ef6f2a66b84 Mon Sep 17 00:00:00 2001 From: trett Date: Tue, 3 Feb 2026 18:34:29 +0100 Subject: [PATCH 1/3] feat(server): add thinking config with LOW mode and temperature to Gemini API - Add thinkingConfig with LOW mode to all Gemini API requests for faster responses - Add temperature parameter (1.5) to fun fact generation for more creative results - Refactor buildGeminiRequest to use circe Json API for type-safe JSON construction - Fun facts now use higher temperature while summaries use default for consistency Co-Authored-By: Claude Sonnet 4.5 --- .../server/services/SummarizeService.scala | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/server/src/main/scala/ru/trett/rss/server/services/SummarizeService.scala b/server/src/main/scala/ru/trett/rss/server/services/SummarizeService.scala index 02d11dd..56db51c 100644 --- a/server/src/main/scala/ru/trett/rss/server/services/SummarizeService.scala +++ b/server/src/main/scala/ru/trett/rss/server/services/SummarizeService.scala @@ -2,8 +2,8 @@ package ru.trett.rss.server.services import cats.effect.IO import io.circe.Decoder +import io.circe.Json import io.circe.generic.auto.* -import io.circe.syntax.* import org.http4s.Header import org.http4s.Headers import org.http4s.Method @@ -46,7 +46,29 @@ class SummarizeService(feedRepository: FeedRepository, client: Client[IO], apiKe s"https://generativelanguage.googleapis.com/v1beta/models/$modelId:generateContent" ) - private def buildGeminiRequest(modelId: String, prompt: String): Request[IO] = + private def buildGeminiRequest( + modelId: String, + prompt: String, + temperature: Option[Double] = None + ): Request[IO] = + var config = Json.obj( + "contents" -> Json.arr( + Json.obj("parts" -> Json.arr(Json.obj("text" -> Json.fromString(prompt)))) + ), + "thinkingConfig" -> Json.obj("thinkingLevel" -> Json.fromString("LOW")) + ) + + config = temperature match + case Some(temp) => + config.mapObject(obj => + obj.add( + "generationConfig", + Json.obj("temperature" -> Json.fromDoubleOrNull(temp)) + ) + ) + case None => + config + Request[IO]( method = Method.POST, uri = getEndpoint(modelId), @@ -54,7 +76,7 @@ class SummarizeService(feedRepository: FeedRepository, client: Client[IO], apiKe Header.Raw(ci"X-goog-api-key", apiKey), Header.Raw(ci"Content-Type", "application/json") ) - ).withEntity(Map("contents" -> List(Map("parts" -> List(Map("text" -> prompt))))).asJson) + ).withEntity(config) def getSummary(user: User, offset: Int): IO[SummaryResponse] = val selectedModel = user.settings.summaryModel @@ -127,7 +149,7 @@ class SummarizeService(feedRepository: FeedRepository, client: Client[IO], apiKe |Do not add any introduction or preamble, just state the fact directly.""".stripMargin client - .expect[GeminiResponse](buildGeminiRequest(modelId, prompt)) + .expect[GeminiResponse](buildGeminiRequest(modelId, prompt, temperature = Some(1.5))) .map { response => response.candidates.headOption .flatMap(_.content.parts.flatMap(_.headOption)) From 4f1880046772d645ae74ae996f7ae7c19c5994fd Mon Sep 17 00:00:00 2001 From: trett Date: Tue, 3 Feb 2026 18:37:33 +0100 Subject: [PATCH 2/3] refactor: replace var with immutable val and functional composition - Replace var config with val baseConfig and val config - Use Option.fold instead of match expression for more functional style - Improves code readability and follows functional programming principles Co-Authored-By: Claude Sonnet 4.5 --- .../rss/server/services/SummarizeService.scala | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/server/src/main/scala/ru/trett/rss/server/services/SummarizeService.scala b/server/src/main/scala/ru/trett/rss/server/services/SummarizeService.scala index 56db51c..cc2198b 100644 --- a/server/src/main/scala/ru/trett/rss/server/services/SummarizeService.scala +++ b/server/src/main/scala/ru/trett/rss/server/services/SummarizeService.scala @@ -51,23 +51,18 @@ class SummarizeService(feedRepository: FeedRepository, client: Client[IO], apiKe prompt: String, temperature: Option[Double] = None ): Request[IO] = - var config = Json.obj( + val baseConfig = Json.obj( "contents" -> Json.arr( Json.obj("parts" -> Json.arr(Json.obj("text" -> Json.fromString(prompt)))) ), "thinkingConfig" -> Json.obj("thinkingLevel" -> Json.fromString("LOW")) ) - config = temperature match - case Some(temp) => - config.mapObject(obj => - obj.add( - "generationConfig", - Json.obj("temperature" -> Json.fromDoubleOrNull(temp)) - ) - ) - case None => - config + val config = temperature.fold(baseConfig) { temp => + baseConfig.mapObject( + _.add("generationConfig", Json.obj("temperature" -> Json.fromDoubleOrNull(temp))) + ) + } Request[IO]( method = Method.POST, From d7b7c9dd8383724835597d88df246eb91d27ea95 Mon Sep 17 00:00:00 2001 From: trett Date: Tue, 3 Feb 2026 19:14:26 +0100 Subject: [PATCH 3/3] fixes --- build.sbt | 2 +- scripts/local-docker/docker-compose.yml | 2 +- .../server/services/SummarizeService.scala | 155 +++++++++++------- .../ru/trett/rss/models/SummaryModel.scala | 9 + 4 files changed, 110 insertions(+), 58 deletions(-) diff --git a/build.sbt b/build.sbt index 0ba7bff..07d74db 100644 --- a/build.sbt +++ b/build.sbt @@ -4,7 +4,7 @@ import org.scalajs.linker.interface.ModuleSplitStyle import scala.sys.process.* -lazy val projectVersion = "2.4.1" +lazy val projectVersion = "2.4.2" lazy val organizationName = "ru.trett" lazy val scala3Version = "3.7.4" lazy val circeVersion = "0.14.15" diff --git a/scripts/local-docker/docker-compose.yml b/scripts/local-docker/docker-compose.yml index 8bd6f0c..decda0c 100644 --- a/scripts/local-docker/docker-compose.yml +++ b/scripts/local-docker/docker-compose.yml @@ -24,7 +24,7 @@ services: - host.docker.internal:host-gateway server: - image: server:2.4.1 + image: server:2.4.2 container_name: rss_server restart: always depends_on: diff --git a/server/src/main/scala/ru/trett/rss/server/services/SummarizeService.scala b/server/src/main/scala/ru/trett/rss/server/services/SummarizeService.scala index cc2198b..05f84d8 100644 --- a/server/src/main/scala/ru/trett/rss/server/services/SummarizeService.scala +++ b/server/src/main/scala/ru/trett/rss/server/services/SummarizeService.scala @@ -50,28 +50,42 @@ class SummarizeService(feedRepository: FeedRepository, client: Client[IO], apiKe modelId: String, prompt: String, temperature: Option[Double] = None - ): Request[IO] = + ): IO[Request[IO]] = val baseConfig = Json.obj( - "contents" -> Json.arr( - Json.obj("parts" -> Json.arr(Json.obj("text" -> Json.fromString(prompt)))) - ), - "thinkingConfig" -> Json.obj("thinkingLevel" -> Json.fromString("LOW")) + "contents" -> Json + .arr(Json.obj("parts" -> Json.arr(Json.obj("text" -> Json.fromString(prompt))))) ) - val config = temperature.fold(baseConfig) { temp => - baseConfig.mapObject( - _.add("generationConfig", Json.obj("temperature" -> Json.fromDoubleOrNull(temp))) - ) - } - - Request[IO]( - method = Method.POST, - uri = getEndpoint(modelId), - headers = Headers( - Header.Raw(ci"X-goog-api-key", apiKey), - Header.Raw(ci"Content-Type", "application/json") - ) - ).withEntity(config) + // Build model-specific thinking configuration + val thinkingConfig = + if SummaryModel.usesThinkingLevel(modelId) then + Json.obj("thinkingLevel" -> Json.fromString("low")) + else if SummaryModel.usesThinkingBudget(modelId) then + Json.obj("thinkingBudget" -> Json.fromInt(1024)) + else Json.obj() // No thinking config for unknown models + + // Build generation config with thinking config and optional temperature + val generationConfig = temperature match + case Some(temp) => + Json.obj( + "thinkingConfig" -> thinkingConfig, + "temperature" -> Json.fromDoubleOrNull(temp) + ) + case None => + Json.obj("thinkingConfig" -> thinkingConfig) + + val config = baseConfig.mapObject(_.add("generationConfig", generationConfig)) + + IO.pure( + Request[IO]( + method = Method.POST, + uri = getEndpoint(modelId), + headers = Headers( + Header.Raw(ci"X-goog-api-key", apiKey), + Header.Raw(ci"Content-Type", "application/json") + ) + ).withEntity(config) + ) def getSummary(user: User, offset: Int): IO[SummaryResponse] = val selectedModel = user.settings.summaryModel @@ -143,19 +157,33 @@ class SummarizeService(feedRepository: FeedRepository, client: Client[IO], apiKe |Do not use markdown formatting. |Do not add any introduction or preamble, just state the fact directly.""".stripMargin - client - .expect[GeminiResponse](buildGeminiRequest(modelId, prompt, temperature = Some(1.5))) - .map { response => - response.candidates.headOption - .flatMap(_.content.parts.flatMap(_.headOption)) - .map(_.text.trim) - .filter(_.nonEmpty) - .getOrElse("") - } - .handleErrorWith { error => - logger.error(error)(s"Error generating fun fact: $error") *> - IO.pure("") - } + buildGeminiRequest(modelId, prompt, temperature = Some(1.5)).flatMap { request => + client + .run(request) + .use { response => + if response.status.isSuccess then + response + .as[GeminiResponse] + .map { geminiResp => + geminiResp.candidates.headOption + .flatMap(_.content.parts.flatMap(_.headOption)) + .map(_.text.trim) + .filter(_.nonEmpty) + .getOrElse("") + } + else + response.bodyText.compile.string.flatMap { body => + logger.error( + s"Gemini API error (fun fact): status=${response.status}, body=$body" + ) *> + IO.pure("") + } + } + .handleErrorWith { error => + logger.error(error)(s"Error generating fun fact: ${error.getMessage}") *> + IO.pure("") + } + } private def summarize(text: String, language: String, modelId: String): IO[SummaryResult] = val prompt = s"""You must follow these rules for your response: @@ -174,27 +202,42 @@ class SummarizeService(feedRepository: FeedRepository, client: Client[IO], apiKe |13. For each topic, list the key stories with brief summaries. |Now, following these rules exactly summarize the following text. Answer in $language: $text.""".stripMargin - client - .expect[GeminiResponse](buildGeminiRequest(modelId, prompt)) - .map { response => - response.candidates.headOption - .flatMap(_.content.parts.flatMap(_.headOption)) - .map(_.text) - .map { text => - if text.startsWith("```html") then - text.stripPrefix("```html").stripSuffix("```").trim - else text.trim - } match - case Some(html) if html.nonEmpty => SummarySuccess(html) - case _ => SummaryError("Could not extract summary from response.") - } - .handleErrorWith { error => - val errorMessage = error match - case _: TimeoutException => - "Summary request timed out. The AI service is taking too long to respond. Please try again with fewer feeds." - case _ => - "Error communicating with the summary API." - logger.error(error)(s"Error summarizing text: $error") *> IO.pure( - SummaryError(errorMessage) - ) - } + buildGeminiRequest(modelId, prompt).flatMap { request => + client + .run(request) + .use { response => + if response.status.isSuccess then + response + .as[GeminiResponse] + .map { geminiResp => + geminiResp.candidates.headOption + .flatMap(_.content.parts.flatMap(_.headOption)) + .map(_.text) + .map { text => + if text.startsWith("```html") then + text.stripPrefix("```html").stripSuffix("```").trim + else text.trim + } match + case Some(html) if html.nonEmpty => SummarySuccess(html) + case _ => + SummaryError("Could not extract summary from response.") + } + else + response.bodyText.compile.string.flatMap { body => + logger.error( + s"Gemini API error: status=${response.status}, body=$body" + ) *> + IO.pure(SummaryError(s"API error: ${response.status.reason}")) + } + } + .handleErrorWith { error => + val errorMessage = error match + case _: TimeoutException => + "Summary request timed out. The AI service is taking too long to respond. Please try again with fewer feeds." + case _ => + "Error communicating with the summary API." + logger.error(error)(s"Error summarizing text: ${error.getMessage}") *> IO.pure( + SummaryError(errorMessage) + ) + } + } diff --git a/shared/src/main/scala/ru/trett/rss/models/SummaryModel.scala b/shared/src/main/scala/ru/trett/rss/models/SummaryModel.scala index d1887e1..6f32a9e 100644 --- a/shared/src/main/scala/ru/trett/rss/models/SummaryModel.scala +++ b/shared/src/main/scala/ru/trett/rss/models/SummaryModel.scala @@ -21,3 +21,12 @@ object SummaryModel: def all: List[SummaryModel] = values.toList def default: SummaryModel = Gemini3FlashPreview + + /** Determines if a model uses thinkingLevel configuration (Gemini 3.x models) */ + def usesThinkingLevel(modelId: String): Boolean = + modelId.contains("gemini-3") + + /** Determines if a model uses thinkingBudget configuration (Gemini 2.5 models and flash-latest) + */ + def usesThinkingBudget(modelId: String): Boolean = + modelId.contains("2.5") || modelId.contains("flash-latest")