From ad9e67282c9f0fbf8b56a8e6141433e6849757fa Mon Sep 17 00:00:00 2001 From: trett Date: Thu, 5 Mar 2026 19:03:50 +0100 Subject: [PATCH] fix(server): always fetch unread feeds from offset 0 in AI mode --- .../server/services/SummarizeService.scala | 63 +++++++++---------- .../services/SummarizeServiceSpec.scala | 41 ++++++++++++ 2 files changed, 70 insertions(+), 34 deletions(-) create mode 100644 server/src/test/scala/ru/trett/rss/server/services/SummarizeServiceSpec.scala 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 1b1ab1e..9a43835 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 @@ -91,40 +91,35 @@ class SummarizeService(feedRepository: FeedRepository, client: Client[IO], apiKe Stream .eval(feedRepository.getTotalUnreadCount(user.id)) .flatMap { totalUnread => - Stream.eval(feedRepository.getUnreadFeeds(user, batchSize, offset)).flatMap { - feeds => - val remainingAfterThis = totalUnread - offset - feeds.size - val metadata = SummaryEvent.Metadata( - feedsProcessed = feeds.size, - totalRemaining = Math.max(0, remainingAfterThis), - hasMore = remainingAfterThis > 0 - ) - - Stream.emit(metadata) ++ ( - if feeds.isEmpty && offset == 0 then - Stream - .eval(generateFunFact(user, selectedModel.modelId)) - .map(SummaryEvent.FunFact(_)) ++ Stream.emit(SummaryEvent.Done) - else if feeds.isEmpty then Stream.emit(SummaryEvent.Done) - else - val text = feeds.map(_.description).mkString("\n") - val strippedText = Jsoup.parse(text).text() - val validatedLanguage = user.settings.summaryLanguage - .flatMap(SummaryLanguage.fromString) - .getOrElse(SummaryLanguage.English) - - Stream - .eval( - if user.settings.isAiMode then - feedRepository.markFeedAsRead(feeds.map(_.link), user) - else IO.unit - ) - .drain ++ summarizeStream( - strippedText, - validatedLanguage.displayName, - selectedModel.modelId - ) - ) + Stream.eval(feedRepository.getUnreadFeeds(user, batchSize, 0)).flatMap { feeds => + val remainingAfterThis = totalUnread - feeds.size + val metadata = SummaryEvent.Metadata( + feedsProcessed = feeds.size, + totalRemaining = Math.max(0, remainingAfterThis), + hasMore = remainingAfterThis > 0 + ) + + Stream.emit(metadata) ++ ( + if feeds.isEmpty && offset == 0 then + Stream + .eval(generateFunFact(user, selectedModel.modelId)) + .map(SummaryEvent.FunFact(_)) ++ Stream.emit(SummaryEvent.Done) + else if feeds.isEmpty then Stream.emit(SummaryEvent.Done) + else + val text = feeds.map(_.description).mkString("\n") + val strippedText = Jsoup.parse(text).text() + val validatedLanguage = user.settings.summaryLanguage + .flatMap(SummaryLanguage.fromString) + .getOrElse(SummaryLanguage.English) + + Stream + .eval(feedRepository.markFeedAsRead(feeds.map(_.link), user)) + .drain ++ summarizeStream( + strippedText, + validatedLanguage.displayName, + selectedModel.modelId + ) + ) } } .handleErrorWith { error => diff --git a/server/src/test/scala/ru/trett/rss/server/services/SummarizeServiceSpec.scala b/server/src/test/scala/ru/trett/rss/server/services/SummarizeServiceSpec.scala new file mode 100644 index 0000000..83a81c8 --- /dev/null +++ b/server/src/test/scala/ru/trett/rss/server/services/SummarizeServiceSpec.scala @@ -0,0 +1,41 @@ +package ru.trett.rss.server.services + +import cats.effect.IO +import cats.effect.unsafe.implicits.global +import org.http4s.client.Client +import org.scalamock.scalatest.MockFactory +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import org.typelevel.log4cats.LoggerFactory +import org.typelevel.log4cats.noop.NoOpFactory +import ru.trett.rss.server.models.User +import ru.trett.rss.server.repositories.FeedRepository + +class SummarizeServiceSpec extends AnyFunSuite with Matchers with MockFactory { + + test("streamSummary should always fetch feeds from offset 0 regardless of provided offset") { + val feedRepository = mock[FeedRepository] + val client = mock[Client[IO]] + val user = User("user-id", "User", "user@example.com", User.Settings()) + + implicit val loggerFactory: LoggerFactory[IO] = NoOpFactory[IO] + + // Mock getTotalUnreadCount + (feedRepository.getTotalUnreadCount) + .expects("user-id") + .returning(IO.pure(60)) + + // This is the CRITICAL part: even if streamSummary is called with offset 30, + // it MUST call feedRepository.getUnreadFeeds with offset 0 because + // it's in AI mode and feeds from the previous batch were already marked as read. + (feedRepository + .getUnreadFeeds(_: User, _: Int, _: Int)) + .expects(user, 30, 0) // batchSize is 30, expected offset is 0 + .returning(IO.pure(List.empty)) + + val service = new SummarizeService(feedRepository, client, "api-key") + + // Call with offset 30 (simulating "Load More" click) + service.streamSummary(user, 30).compile.toList.unsafeRunSync() + } +}