diff --git a/.gitignore b/.gitignore index 4f0be98..c5bd65c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,9 @@ docker .idea -.codemie \ No newline at end of file +.codemie + +.vscode + +data/ml_models/ +phase1_datasets/ \ No newline at end of file diff --git a/apiGateway/src/main/java/com/library/apiGateway/configs/SecurityConfig.java b/apiGateway/src/main/java/com/library/apiGateway/configs/SecurityConfig.java index d87a119..eb1f949 100644 --- a/apiGateway/src/main/java/com/library/apiGateway/configs/SecurityConfig.java +++ b/apiGateway/src/main/java/com/library/apiGateway/configs/SecurityConfig.java @@ -9,9 +9,6 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.util.Arrays; -import java.util.List; - @Configuration public class SecurityConfig { @@ -36,19 +33,42 @@ public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception { return httpSecurity + .cors(cors -> cors.configurationSource(corsConfigurationSource())) .authorizeHttpRequests(authorize -> authorize .requestMatchers(freeResourceUrls).permitAll() .anyRequest().authenticated()) - .cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource())) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) .build(); } + + /** + * CORS configuration for API Gateway. + * Frontend makes requests to the gateway, which proxies to internal services. + * Swagger UI on the gateway also needs to be allowed. + * Services do NOT need their own CORS config - this is the single point of CORS handling. + */ @Bean - CorsConfigurationSource corsConfigurationSource() { + public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(List.of("*")); - configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); - configuration.setAllowedHeaders(List.of("*")); + + // Allow frontend origins + configuration.setAllowedOriginPatterns(java.util.List.of( + "http://localhost:4200", // Angular dev + "http://127.0.0.1:4200" + )); + + // Allow all HTTP methods + configuration.setAllowedMethods(java.util.List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); + + // Allow all headers (including Authorization for JWT) + configuration.setAllowedHeaders(java.util.List.of("*")); + + // CRITICAL: Allow credentials (JWT tokens in Authorization header) + configuration.setAllowCredentials(true); + + // Cache preflight for 1 hour + configuration.setMaxAge(3600L); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; diff --git a/book-bazaar/package-lock.json b/book-bazaar/package-lock.json index f61482b..8b881f6 100644 --- a/book-bazaar/package-lock.json +++ b/book-bazaar/package-lock.json @@ -441,7 +441,6 @@ "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-20.2.11.tgz", "integrity": "sha512-+zcP6eq9+h6f09rZWHNIj2nap9P6S38mm75/WjdGZbl1BJy7vaASDnr4fwXKi2JvTyap/vj6mMuadFXEivavPw==", "license": "MIT", - "peer": true, "dependencies": { "parse5": "^8.0.0", "tslib": "^2.3.0" @@ -492,7 +491,6 @@ "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.10.tgz", "integrity": "sha512-12fEzvKbEqjqy1fSk9DMYlJz6dF1MJVXuC5BB+oWWJpd+2lfh4xJ62pkvvLGAICI89hfM5n9Cy5kWnXwnqPZsA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -509,7 +507,6 @@ "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.10.tgz", "integrity": "sha512-cW939Lr8GZjPSYfbQKIDNrUaHWmn2M+zBbERThfq5skLuY+xM60bJFv4NqBekfX6YqKLCY62ilUZlnImYIXaqA==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -523,7 +520,6 @@ "integrity": "sha512-9BemvpFxA26yIVdu8ROffadMkEdlk/AQQ2Jb486w7RPkrvUQ0pbEJukhv9aryJvhbMopT66S5H/j4ipOUMzmzQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "7.28.3", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -556,7 +552,6 @@ "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.10.tgz", "integrity": "sha512-g99Qe+NOVo72OLxowVF9NjCckswWYHmvO7MgeiZTDJbTjF9tXH96dMx7AWq76/GUinV10sNzDysVW16NoAbCRQ==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -582,7 +577,6 @@ "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.10.tgz", "integrity": "sha512-9yWr51EUauTEINB745AaHwZNTHLpXIm4uxuykxzOg+g2QskEgVfH26uS8G2ogdNuwYpB8wnsXWr34qhM3qgOWw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -618,7 +612,6 @@ "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.10.tgz", "integrity": "sha512-UV8CGoB5P3FmJciI3/I/n3L7C3NVgGh7bIlZ1BaB/qJDtv0Wq0rRAGwmT/Z3gwmrRtfHZWme7/CeQ2CYJmMyUQ==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -641,7 +634,6 @@ "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.10.tgz", "integrity": "sha512-Z03cfH1jgQ7XMDJj4R8qAGqivcvhdG3wYBwaiN1K1ODBgPhbFKNeD4stKqYp7xBNtswmM2O2jMxrL/Djwju4Gg==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -686,7 +678,6 @@ "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1637,7 +1628,6 @@ "integrity": "sha512-nqhDw2ZcAUrKNPwhjinJny903bRhI0rQhiDz1LksjeRxqa36i3l75+4iXbOy0rlDpLJGxqtgoPavQjmmyS5UJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/checkbox": "^4.2.1", "@inquirer/confirm": "^5.1.14", @@ -3870,7 +3860,6 @@ "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4242,7 +4231,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -5339,7 +5327,6 @@ "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -6305,8 +6292,7 @@ "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.9.0.tgz", "integrity": "sha512-OMUvF1iI6+gSRYOhMrH4QYothVLN9C3EJ6wm4g7zLJlnaTl8zbaPOr0bTw70l7QxkoM7sVFOWo83u9B2Fe2Zng==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/jiti": { "version": "2.6.1", @@ -6400,7 +6386,6 @@ "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -6883,7 +6868,6 @@ "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.1.tgz", "integrity": "sha512-bZt6fQj/TLBAmivXSxSlqAJxBx/knNZDQGJIW4ensGYGN4N6tUKV8Zj3Y7/LOV8eIpvWsvqV70fbACihK8Ze0Q==", "license": "Apache-2.0", - "peer": true, "workspaces": [ "test" ] @@ -7143,7 +7127,6 @@ "integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cli-truncate": "^4.0.0", "colorette": "^2.0.20", @@ -8514,7 +8497,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8819,7 +8801,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -8876,7 +8857,6 @@ "integrity": "sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -9725,8 +9705,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tuf-js": { "version": "3.1.0", @@ -9764,7 +9743,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9951,7 +9929,6 @@ "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -10355,7 +10332,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/book-bazaar/src/app/components/book-details/book-details.css b/book-bazaar/src/app/components/book-details/book-details.css index f806e9c..36e3088 100644 --- a/book-bazaar/src/app/components/book-details/book-details.css +++ b/book-bazaar/src/app/components/book-details/book-details.css @@ -32,6 +32,29 @@ outline: none; } +/* Scrollable similar books container */ +.similar-books-scroll { + scrollbar-width: thin; + scrollbar-color: rgba(156, 163, 175, 0.5) transparent; +} + +.similar-books-scroll::-webkit-scrollbar { + width: 6px; +} + +.similar-books-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.similar-books-scroll::-webkit-scrollbar-thumb { + background: rgba(156, 163, 175, 0.5); + border-radius: 3px; +} + +.similar-books-scroll::-webkit-scrollbar-thumb:hover { + background: rgba(107, 114, 128, 0.7); +} + /* Тінь для книги */ .book-cover-container { box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.2), 0 8px 10px -6px rgba(0, 0, 0, 0.2); diff --git a/book-bazaar/src/app/components/book-details/book-details.html b/book-bazaar/src/app/components/book-details/book-details.html index 1b153d7..7242022 100644 --- a/book-bazaar/src/app/components/book-details/book-details.html +++ b/book-bazaar/src/app/components/book-details/book-details.html @@ -109,9 +109,56 @@

Book Details -
+
-
+ +
+
+

Similar Books

+ + @if (isLoadingSimilar()) { +
+ +
+ } @else if (similarBooks().length === 0) { +

No similar books found.

+ } @else { +
+ @for (book of similarBooks(); track book.id) { +
+
+ +
+

+ {{ book.title }} +

+

{{ book.author?.name }}

+
+ + star + {{ book.averageRating?.toFixed(1) }} + + ({{ book.totalReviews }}) +
+
+ } +
+ } +
+
+ + +
+ +
+ +

Community Reviews

@@ -185,11 +232,11 @@

{{ currentUserReview() ? 'Edit Review' : 'Write a Review' }}

-
+
-
- -
+ +
+

Reviews @if(selectedRatingFilter()) { @@ -229,7 +276,6 @@

-
@if (reviewsLoading()) {
@@ -303,5 +349,7 @@

+
+
} \ No newline at end of file diff --git a/book-bazaar/src/app/components/book-details/book-details.ts b/book-bazaar/src/app/components/book-details/book-details.ts index 113c97a..b4934cd 100644 --- a/book-bazaar/src/app/components/book-details/book-details.ts +++ b/book-bazaar/src/app/components/book-details/book-details.ts @@ -1,5 +1,5 @@ -import { Component, inject, OnInit, signal } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; +import { Component, inject, OnInit, signal, effect } from '@angular/core'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { BookService } from '../../services/book/book-service'; import { Book } from '../../model/book'; import { MatButtonModule } from '@angular/material/button'; @@ -14,10 +14,13 @@ import { PageEvent, MatPaginator } from '@angular/material/paginator'; import { MatFormField } from "@angular/material/input"; import { MatOption } from "@angular/material/core"; import { MatSelectModule } from '@angular/material/select'; -import { DatePipe, CurrencyPipe, NgClass, DecimalPipe } from '@angular/common' +import { DatePipe, CurrencyPipe, NgClass, DecimalPipe, SlicePipe } from '@angular/common' import { UserService } from '../../services/user/userService'; import { ReviewRequest } from '../../model/review-request'; import { MatMenuModule } from '@angular/material/menu'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { map } from 'rxjs'; @Component({ selector: 'app-book-details', @@ -36,21 +39,35 @@ import { MatMenuModule } from '@angular/material/menu'; MatSelectModule, DecimalPipe, NgClass, - MatMenuModule + MatMenuModule, + MatTooltipModule, + SlicePipe, + RouterLink ], templateUrl: './book-details.html', styleUrl: './book-details.css', }) -export class BookDetails implements OnInit { +export class BookDetails { protected router = inject(Router); private route = inject(ActivatedRoute); private bookService = inject(BookService); private reviewService = inject(ReviewService); + // Track bookId from route params + private bookIdFromRoute = toSignal( + this.route.paramMap.pipe( + map(params => Number(params.get('bookId'))) + ) + ); + book = signal(null); loading = signal(true); - userService = inject(UserService); // Інжект юзера + // Similar books loading + similarBooks = signal([]); + isLoadingSimilar = signal(false); + + userService = inject(UserService); currentUserReview = signal(null); reviews = signal([]); @@ -66,15 +83,21 @@ export class BookDetails implements OnInit { userRating = signal(0); hoverRating = signal(0); - ngOnInit(): void { - const id = this.route.snapshot.paramMap.get('bookId'); - if (id) { - const bookId = Number(id); - this.loadBook(bookId); - this.loadMetrics(bookId); - this.loadReviews(bookId); - this.checkUserReview(bookId); - } + constructor() { + // Реагувати на зміни bookId з URL + effect(() => { + const bookId = this.bookIdFromRoute(); + if (bookId && bookId > 0) { + this.loadBook(bookId); + this.loadMetrics(bookId); + this.loadReviews(bookId); + this.loadSimilarBooks(bookId); + this.checkUserReview(bookId); + // Скинути пагінацію та фільтри + this.pageIndex.set(0); + this.selectedRatingFilter.set(undefined); + } + }); } private loadBook(id: number) { @@ -90,6 +113,20 @@ export class BookDetails implements OnInit { } }); } + private loadSimilarBooks(bookId: number) { + this.isLoadingSimilar.set(true); + this.bookService.getSimilarBooks(bookId, 10).subscribe({ + next: (response) => { + this.similarBooks.set(response.items || []); + this.isLoadingSimilar.set(false); + }, + error: (error) => { + console.error('Error loading similar books:', error); + this.similarBooks.set([]); + this.isLoadingSimilar.set(false); + } + }); + } private loadMetrics(id: number) { this.reviewService.getBookMetrics(id).subscribe({ @@ -253,4 +290,49 @@ export class BookDetails implements OnInit { default: return 'Sort by'; } } + + /** + * Get the reason why a book is similar to the current book + */ + getSimilarityReason(similarBook: Book): string { + const currentBook = this.book(); + if (!currentBook) return 'Similar book'; + + // NEW: Check if book has explanation from recommendations API + const bookWithExplanation = similarBook as any; + if (bookWithExplanation.explanation?.primaryReason) { + const explanation = bookWithExplanation.explanation; + const reason = explanation.primaryReason; + const contributors = explanation.topContributors?.join(', ') || ''; + + if (contributors) { + return `${reason}\n(${contributors})`; + } + return reason; + } + + // FALLBACK: Existing logic if no explanation from API + // Check if same author + if (currentBook.author?.id === similarBook.author?.id) { + return `Also by ${currentBook.author?.name}`; + } + + // Check for shared genres + const sharedGenres = currentBook.bookGenres?.filter(g => + similarBook.bookGenres?.some(sg => sg.id === g.id) + ) || []; + + if (sharedGenres.length > 0) { + return `Also in ${sharedGenres[0].name}`; + } + + // Check similar rating + const currentRating = currentBook.averageRating || 0; + const similarRating = similarBook.averageRating || 0; + if (Math.abs(currentRating - similarRating) < 0.5) { + return 'Similarly rated'; + } + + return 'Readers also enjoyed'; + } } \ No newline at end of file diff --git a/book-bazaar/src/app/components/filter-panel/filter-panel.html b/book-bazaar/src/app/components/filter-panel/filter-panel.html index 1343517..518702a 100644 --- a/book-bazaar/src/app/components/filter-panel/filter-panel.html +++ b/book-bazaar/src/app/components/filter-panel/filter-panel.html @@ -10,31 +10,57 @@

@for (option of visibleOptions(filter); track option) { -

} diff --git a/book-bazaar/src/app/components/filter-panel/filter-panel.ts b/book-bazaar/src/app/components/filter-panel/filter-panel.ts index 3a54a8e..4f1b409 100644 --- a/book-bazaar/src/app/components/filter-panel/filter-panel.ts +++ b/book-bazaar/src/app/components/filter-panel/filter-panel.ts @@ -1,17 +1,20 @@ import { Component, computed, effect, input, output, signal, SimpleChange, SimpleChanges } from '@angular/core'; import {Filter} from '../../utils/filter'; -import {NgIf} from '@angular/common'; +import {NgIf, CommonModule} from '@angular/common'; import {MatCheckbox} from '@angular/material/checkbox'; import {MatButton} from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; +import { FormsModule } from '@angular/forms'; @Component({ selector: 'app-filter-panel', imports: [ NgIf, + CommonModule, MatCheckbox, MatButton, - MatIcon + MatIcon, + FormsModule ], templateUrl: './filter-panel.html', styleUrl: './filter-panel.css', @@ -26,6 +29,9 @@ export class FilterPanel { changed = output>(); + // Сигнали для пошуку в кожному фільтрі + searchTexts = signal>({}); + hasSelectedFilters = computed(() => { const selected = this.currentSelected(); return Object.values(selected).some(arr => arr && arr.length > 0); @@ -41,9 +47,31 @@ export class FilterPanel { } visibleOptions(filter: Filter): string[] { - return filter.expanded - ? filter.options - : filter.options.slice(0, filter.defaultVisibleCount); + const searchText = this.searchTexts()[filter.name]?.toLowerCase() || ''; + let filtered = filter.options; + + // Якщо є пошук, фільтруємо за ним + if (searchText) { + filtered = filter.options.filter(option => + option.toLowerCase().includes(searchText) + ); + } else { + // Якщо пошук порожній + // - Якщо розгорнутий, показуємо ВСІ елементи + // - Якщо згорнутий, показуємо тільки перші defaultVisibleCount + if (!filter.expanded) { + filtered = filter.options.slice(0, filter.defaultVisibleCount); + } + } + + return filtered; + } + + updateSearchText(filterName: string, text: string) { + this.searchTexts.update(texts => ({ + ...texts, + [filterName]: text + })); } toggleOption(filterName: string, option: string) { @@ -66,7 +94,13 @@ export class FilterPanel { toggleExpanded(filterName: string) { const filter = this.filters().find(f => f.name === filterName); - if (filter) filter.expanded = !filter.expanded; + if (filter) { + filter.expanded = !filter.expanded; + // Очищуємо пошук при згортанні + if (!filter.expanded) { + this.updateSearchText(filterName, ''); + } + } } isSelected(filterName: string, option: string): boolean { diff --git a/book-bazaar/src/app/components/home/home.html b/book-bazaar/src/app/components/home/home.html index dd1c2ce..56a5b95 100644 --- a/book-bazaar/src/app/components/home/home.html +++ b/book-bazaar/src/app/components/home/home.html @@ -93,9 +93,59 @@

+ + + + + +
+
+ +
+ @if (recommendationType() === 'personal') { +

Recommended for You

+

Based on your reading history

+ } @else { +

Popular Books

+

Trending now in our library

+ } +
+ + @if (isLoadingRecommendations()) { +
+ +
+ } @else if (recommendedBooks().length === 0) { +

No recommendations available at the moment.

+ } @else { +
+ @for (book of recommendedBooks(); track book.id) { +
+
+ +
+

{{ book.title }}

+

{{ book.author?.name }}

+
+ ★ {{ book.averageRating?.toFixed(1) }} + ({{ book.totalReviews }}) +
+

{{ book.price | currency }}

+
+
+
+ } +
+ }
+
@@ -160,4 +210,4 @@

- \ No newline at end of file + diff --git a/book-bazaar/src/app/components/home/home.ts b/book-bazaar/src/app/components/home/home.ts index af3b1de..925d7bc 100644 --- a/book-bazaar/src/app/components/home/home.ts +++ b/book-bazaar/src/app/components/home/home.ts @@ -1,9 +1,10 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, inject, signal, OnInit } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { Router, RouterModule } from '@angular/router'; import { Genre } from '../../model/genre'; import { MatRipple } from '@angular/material/core'; import { MatButton } from '@angular/material/button'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { Book } from '../../model/book'; import { BookService } from '../../services/book/book-service'; import { BookCard } from "../book-card/book-card"; @@ -13,6 +14,8 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { of } from 'rxjs'; import { CurrencyPipe, DecimalPipe } from '@angular/common'; import { MatIcon } from "@angular/material/icon"; +import { UserService } from '../../services/user/userService'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; @Component({ selector: 'app-home', @@ -24,15 +27,19 @@ import { MatIcon } from "@angular/material/icon"; CurrencyPipe, DecimalPipe, RouterModule, - MatIcon -], + MatIcon, + MatTooltipModule, + MatProgressSpinnerModule + ], templateUrl: './home.html', styleUrl: './home.css', }) -export class Home { +export class Home implements OnInit { protected router = inject(Router); protected bookService = inject(BookService); protected genreService = inject(GenreService); + protected userService = inject(UserService); + protected formGroup: FormGroup = new FormGroup( { search: new FormControl('') @@ -42,6 +49,11 @@ export class Home { protected browsingGenres = signal([]); protected popularBooks = signal([]); + // Personalized recommendations + protected recommendedBooks = signal([]); + protected isLoadingRecommendations = signal(false); + protected recommendationType = signal<'personal' | 'popular'>('popular'); + protected liveSearchResults = signal([]); protected showDropdown = signal(false); @@ -50,8 +62,7 @@ export class Home { } ngOnInit() { - this.bookService.getBooks({}, 0, 5) - .subscribe(page => this.popularBooks.set(page.items)); + this.loadRecommendations(); this.genreService.getGenres(0, 6) .subscribe(page => this.browsingGenres.set(page.items)); } @@ -86,6 +97,51 @@ export class Home { }); } + /** + * Load personalized recommendations (if logged in) or popular books (if not) + */ + protected loadRecommendations(): void { + this.isLoadingRecommendations.set(true); + + if (this.userService.isLoggedIn()) { + // Logged in: try to get personalized recommendations + this.bookService.getPersonalizedRecommendations(10).subscribe({ + next: (response) => { + this.recommendedBooks.set(response.items || []); + this.recommendationType.set('personal'); + this.isLoadingRecommendations.set(false); + }, + error: (error) => { + console.warn('Could not load personalized recommendations:', error); + // Fallback to popular books + this.loadPopularBooks(); + } + }); + } else { + // Not logged in: load popular books + this.loadPopularBooks(); + } + } + + /** + * Load popular books (fallback for new/unauthenticated users) + */ + protected loadPopularBooks(): void { + this.bookService.getBooks({}, 0, 10) + .subscribe({ + next: (response) => { + this.recommendedBooks.set(response.items || []); + this.recommendationType.set('popular'); + this.isLoadingRecommendations.set(false); + }, + error: (error) => { + console.error('Error loading popular books:', error); + this.recommendedBooks.set([]); + this.isLoadingRecommendations.set(false); + } + }); + } + protected search() { let filter = this.formGroup.value.search; if (!filter) return; @@ -100,4 +156,21 @@ export class Home { closeDropdown() { setTimeout(() => this.showDropdown.set(false), 200); } + + /** + * Get recommendation reason for tooltip display + */ + protected getRecommendationReason(book: Book): string { + const bookWithExplanation = book as any; + + if (bookWithExplanation.explanation?.primaryReason) { + const explanation = bookWithExplanation.explanation; + return explanation.primaryReason; + } + + // Fallback message + return this.recommendationType() === 'personal' + ? 'Recommended for you' + : 'Popular book'; + } } diff --git a/book-bazaar/src/app/components/search-books/search-books.ts b/book-bazaar/src/app/components/search-books/search-books.ts index 80e2224..7969edd 100644 --- a/book-bazaar/src/app/components/search-books/search-books.ts +++ b/book-bazaar/src/app/components/search-books/search-books.ts @@ -168,10 +168,10 @@ export class SearchBooks { private loadFilters(): Observable { return forkJoin({ - authors: this.authorService.getAuthors(0, 20), - genres: this.genreService.getGenres(0, 20), - categories: this.categoryService.getCategories(0, 20), - publishers: this.publisherService.getPublishers(0, 20) + authors: this.authorService.getAuthors(0, 100), + genres: this.genreService.getGenres(0, 100), + categories: this.categoryService.getCategories(0, 100), + publishers: this.publisherService.getPublishers(0, 100) }).pipe( map((res: { authors: { items: any; }; genres: { items: any; }; categories: { items: any; }; publishers: { items: any; }; }) => { return [ diff --git a/book-bazaar/src/app/services/book/book-service.ts b/book-bazaar/src/app/services/book/book-service.ts index d388728..d3bb204 100644 --- a/book-bazaar/src/app/services/book/book-service.ts +++ b/book-bazaar/src/app/services/book/book-service.ts @@ -83,4 +83,25 @@ export class BookService { getBookById(id: number): Observable { return this.http.get(`${this.baseUrl}/${id}`); } + + /** + * Get similar books for a given book (content-based filtering) + * @param bookId - Book ID to find similar books for + * @param topK - Number of recommendations (default: 10) + */ + getSimilarBooks(bookId: number, topK: number = 10): Observable> { + let params = new HttpParams().set('topK', topK.toString()); + return this.http.get>(`${this.baseUrl}/${bookId}/similar`, { params }); + } + + /** + * Get personalized recommendations for authenticated user (collaborative filtering) + * Falls back to popular books if user has no review history + * @param topK - Number of recommendations (default: 10) + */ + getPersonalizedRecommendations(topK: number = 10): Observable> { + let params = new HttpParams().set('topK', topK.toString()); + return this.http.get>(`${this.baseUrl}/recommendations/personal`, { params }); + } } + diff --git a/bookService/pom.xml b/bookService/pom.xml index 39befbb..11a0740 100644 --- a/bookService/pom.xml +++ b/bookService/pom.xml @@ -28,6 +28,7 @@ 17 + 2023.0.3 @@ -53,7 +54,7 @@ - + org.springframework.boot spring-boot-starter-web @@ -68,12 +69,16 @@ springdoc-openapi-starter-webmvc-ui 2.1.0 + + org.springframework.cloud + spring-cloud-starter-openfeign + org.projectlombok lombok true - + org.springframework.boot spring-boot-starter-data-jpa @@ -91,13 +96,13 @@ mysql-connector-j runtime - + org.springframework.boot spring-boot-starter-test test - + org.springframework.kafka spring-kafka @@ -122,12 +127,12 @@ avro-maven-plugin 1.12.0 - + org.springframework.boot spring-boot-starter-oauth2-resource-server - + org.springframework.boot spring-boot-starter-actuator @@ -137,6 +142,17 @@ micrometer-registry-prometheus + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + bookService diff --git a/bookService/src/main/java/org/library/bookservice/BookServiceApplication.java b/bookService/src/main/java/org/library/bookservice/BookServiceApplication.java index 268dd2a..7d50893 100644 --- a/bookService/src/main/java/org/library/bookservice/BookServiceApplication.java +++ b/bookService/src/main/java/org/library/bookservice/BookServiceApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; @SpringBootApplication +@EnableFeignClients(basePackages = "org.library.bookservice.client") public class BookServiceApplication { public static void main(String[] args) { diff --git a/bookService/src/main/java/org/library/bookservice/client/RecommenderServiceClient.java b/bookService/src/main/java/org/library/bookservice/client/RecommenderServiceClient.java new file mode 100644 index 0000000..92666d0 --- /dev/null +++ b/bookService/src/main/java/org/library/bookservice/client/RecommenderServiceClient.java @@ -0,0 +1,45 @@ +package org.library.bookservice.client; + +import org.library.bookservice.dto.recommender.*; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import java.util.List; + +/** + * OpenFeign client for recommenderService + */ +@FeignClient( + name = "recommender-service", + url = "${recommender-service.url}", + fallback = RecommenderServiceClientFallback.class +) +public interface RecommenderServiceClient { + + /** + * Get similar books using content-based filtering + * Called in real-time (no caching at this level) + */ + @PostMapping("/api/v1/recommendations/similar-books") + ResponseEntity> getSimilarBooks( + @RequestBody GetSimilarBooksRequest request + ); + + /** + * Get personalized recommendations (pre-computed collaborative filtering) + * Results cached in MongoDB by bookService + */ + @PostMapping("/api/v1/recommendations/personalized") + ResponseEntity> getPersonalizedRecommendations( + @RequestBody GetPersonalizedRecommendationsRequest request + ); + + /** + * Health check endpoint + */ + @GetMapping("/api/v1/recommendations/health") + ResponseEntity getHealth(); +} diff --git a/bookService/src/main/java/org/library/bookservice/client/RecommenderServiceClientFallback.java b/bookService/src/main/java/org/library/bookservice/client/RecommenderServiceClientFallback.java new file mode 100644 index 0000000..5c1ec93 --- /dev/null +++ b/bookService/src/main/java/org/library/bookservice/client/RecommenderServiceClientFallback.java @@ -0,0 +1,34 @@ +package org.library.bookservice.client; + +import org.library.bookservice.dto.recommender.*; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; + +/** + * Fallback implementation for RecommenderServiceClient (circuit breaker pattern) + */ +@Component +public class RecommenderServiceClientFallback implements RecommenderServiceClient { + + @Override + public ResponseEntity> getSimilarBooks(GetSimilarBooksRequest request) { + // Return empty list when recommender service is down + return ResponseEntity.ok(Collections.emptyList()); + } + + @Override + public ResponseEntity> getPersonalizedRecommendations( + GetPersonalizedRecommendationsRequest request) { + // Return empty list when recommender service is down + return ResponseEntity.ok(Collections.emptyList()); + } + + @Override + public ResponseEntity getHealth() { + // Service unavailable + return ResponseEntity.status(503).body(null); + } +} diff --git a/bookService/src/main/java/org/library/bookservice/client/ReviewServiceClient.java b/bookService/src/main/java/org/library/bookservice/client/ReviewServiceClient.java new file mode 100644 index 0000000..78efb13 --- /dev/null +++ b/bookService/src/main/java/org/library/bookservice/client/ReviewServiceClient.java @@ -0,0 +1,38 @@ +package org.library.bookservice.client; + +import org.library.bookservice.dto.PageResponse; +import org.library.bookservice.dto.review.ReviewResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * OpenFeign client for reviewService + */ +@FeignClient( + name = "review-service", + url = "${review-service.url}" +) +public interface ReviewServiceClient { + + /** + * Get all reviews for a specific user (for recommendations) + * Returns all reviews without pagination limits + */ + @GetMapping("/api/reviews/user") + ResponseEntity> getUserReviewedBooks( + @RequestParam String userId + ); + + /** + * Search reviews by userId to get user's review history (for recommendations) + */ + @GetMapping("/api/reviews") + ResponseEntity> searchReviews( + @RequestParam(defaultValue = "0") Integer page, + @RequestParam(defaultValue = "50") Integer size, + @RequestParam(defaultValue = "createdAt,desc") String sort, + @RequestParam String search + ); +} diff --git a/bookService/src/main/java/org/library/bookservice/config/SecurityConfig.java b/bookService/src/main/java/org/library/bookservice/config/SecurityConfig.java index 897463d..2a8e638 100644 --- a/bookService/src/main/java/org/library/bookservice/config/SecurityConfig.java +++ b/bookService/src/main/java/org/library/bookservice/config/SecurityConfig.java @@ -1,6 +1,5 @@ package org.library.bookservice.config; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -11,22 +10,12 @@ import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.util.List; import java.util.stream.Stream; @Configuration public class SecurityConfig { - @Value("${gateway.url}") - private String gatewayUrl; - - @Value("${review-service.url}") - private String reviewServiceUrl; - private final String[] freeResourceUrls = { "/swagger-ui.html/**", "/swagger-ui/**", @@ -40,7 +29,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti http.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); http -// .cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource())) .authorizeHttpRequests(authorize -> authorize .requestMatchers(freeResourceUrls).permitAll() .requestMatchers("/actuator/**").permitAll() diff --git a/bookService/src/main/java/org/library/bookservice/controllers/BookController.java b/bookService/src/main/java/org/library/bookservice/controllers/BookController.java index 3dee412..d6f2d8d 100644 --- a/bookService/src/main/java/org/library/bookservice/controllers/BookController.java +++ b/bookService/src/main/java/org/library/bookservice/controllers/BookController.java @@ -1,9 +1,20 @@ package org.library.bookservice.controllers; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.library.bookservice.client.RecommenderServiceClient; +import org.library.bookservice.client.ReviewServiceClient; +import org.library.bookservice.dto.PageResponse; import org.library.bookservice.dto.book.BookRequest; import org.library.bookservice.dto.book.BookResponse; +import org.library.bookservice.dto.recommender.ExplanationDetails; +import org.library.bookservice.dto.recommender.GetPersonalizedRecommendationsRequest; +import org.library.bookservice.dto.recommender.GetSimilarBooksRequest; +import org.library.bookservice.dto.recommender.PersonalizedRecommendationResponse; +import org.library.bookservice.dto.recommender.SimilarBookResponse; +import org.library.bookservice.dto.review.ReviewResponse; import org.library.bookservice.filtering.model.EntityFilterSpecificationBuilder; import org.library.bookservice.filtering.model.book.BookSpecificationBuilder; import org.library.bookservice.mapper.BookMapper; @@ -11,21 +22,32 @@ import org.library.bookservice.model.Book; import org.library.bookservice.service.AbstractService; import org.library.bookservice.service.BookService; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; -import java.util.NoSuchElementException; +import java.util.*; +import java.util.stream.Collectors; @Slf4j @RestController @AllArgsConstructor @RequestMapping("/api/books") +@SecurityRequirement(name = "standardFlow") +@SecurityRequirement(name = "clientCredentialsFlow") public class BookController extends AbstractController { private final BookService service; private final BookMapper mapper; - private final BookSpecificationBuilder specificationBuilder; + private final RecommenderServiceClient recommenderServiceClient; + private final ReviewServiceClient reviewServiceClient; @Override protected AbstractService getService() { @@ -45,13 +67,265 @@ protected EntityFilterSpecificationBuilder getSpecificationBuilder() { @Override protected void executeEntityDelete(Integer id) { getService().getById(id).ifPresentOrElse(book -> { - if(book.isArchived()) { - log.info("Archived book was tried to delete. ID: {}", book.getId()); - return; + if (book.isArchived()) { + log.info("Archived book was tried to delete. ID: {}", book.getId()); + return; } getService().delete(book); }, () -> { throw new NoSuchElementException(); }); } + + /** + * Get similar books for a given book using content-based filtering + * Real-time computation (no caching) + */ + @GetMapping("/{bookId}/similar") + @Operation(summary = "Get similar books", description = "Get similar books using content-based filtering (TF-IDF)") + public ResponseEntity> getSimilarBooks( + @PathVariable Integer bookId, + @RequestParam(defaultValue = "10") Integer topK, + Pageable pageable) { + + try { + // Validate book exists + Optional seedBook = service.getById(bookId); + if (seedBook.isEmpty()) { + throw new NoSuchElementException("Book not found: " + bookId); + } + + // Call recommenderService (content-based, real-time) + GetSimilarBooksRequest request = GetSimilarBooksRequest.builder() + .bookId(bookId) + .topK(Math.min(topK, 50)) // Max 50 + .build(); + + ResponseEntity> similarResponse = + recommenderServiceClient.getSimilarBooks(request); + + if (similarResponse.getStatusCode() != HttpStatus.OK || similarResponse.getBody() == null) { + return ResponseEntity.ok(PageResponse.builder() + .size(0) + .total(0L) + .pageNumber(0) + .items(Collections.emptyList()) + .build()); + } + + // Fetch full book details for each similar book + List similarRecs = similarResponse.getBody(); + List similarBooks = new ArrayList<>(); + + // Create map to store explanations + Map explanations = new HashMap<>(); + + for (SimilarBookResponse rec : similarRecs) { + try { + Optional book = service.getById(rec.getBookId()); + if (book.isPresent() && !book.get().isArchived()) { + BookResponse bookResponse = getMapper().entityToResponse(book.get()); + // Preserve explanation from recommenderService + if (rec.getExplanation() != null) { + bookResponse.setExplanation(rec.getExplanation()); + explanations.put(rec.getBookId(), rec.getExplanation()); + } + similarBooks.add(bookResponse); + } + } catch (Exception e) { + log.warn("Failed to fetch similar book details: {}", rec.getBookId()); + } + } + + // Return paginated response + PageResponse result = PageResponse.builder() + .size(pageable.getPageSize()) + .total((long) similarBooks.size()) + .pageNumber(pageable.getPageNumber()) + .items(similarBooks) + .build(); + + return ResponseEntity.ok(result); + + } catch (NoSuchElementException e) { + log.error("Seed book not found: {}", bookId); + return ResponseEntity.notFound().build(); + } catch (Exception e) { + log.error("Error fetching similar books", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + /** + * Get personalized recommendations for authenticated user + * Uses item-item collaborative filtering (real-time, no caching) + * Falls back to popular books if user has no history + */ + @GetMapping("/recommendations/personal") + @PreAuthorize("isAuthenticated()") + @Operation(summary = "Get personalized recommendations", description = "Get recommendations for authenticated user based on review history") + public ResponseEntity> getPersonalizedRecommendations( + @RequestParam(defaultValue = "10") Integer topK, + @AuthenticationPrincipal Jwt jwt, + Pageable pageable) { + + try { + // Extract user ID from JWT + String userId = jwt.getClaimAsString("sub"); + if (userId == null) { + throw new SecurityException("User ID not found in token"); + } + + // Fetch user's complete review history (all reviews, no pagination limit) + ResponseEntity> reviewResponse = + reviewServiceClient.getUserReviewedBooks(userId); + + List seedBookIds = new ArrayList<>(); + if (reviewResponse.getStatusCode() == HttpStatus.OK && reviewResponse.getBody() != null) { + // Extract unique book IDs from reviews + seedBookIds = reviewResponse.getBody().getItems().stream() + .map(ReviewResponse::getBookId) + .distinct() + .collect(Collectors.toList()); + } + + List recommendations = new ArrayList<>(); + + // Case 1: User has review history → use collaborative filtering (real-time) + if (!seedBookIds.isEmpty()) { + recommendations = computePersonalizedRecommendations(seedBookIds, topK); + } + + // Case 2: No history → use popular books + if (recommendations.isEmpty()) { + recommendations = getPopularBooks(topK); + } + + PageResponse result = PageResponse.builder() + .size(pageable.getPageSize()) + .total((long) recommendations.size()) + .pageNumber(pageable.getPageNumber()) + .items(recommendations) + .build(); + + return ResponseEntity.ok(result); + + } catch (SecurityException e) { + log.error("Unauthorized: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } catch (Exception e) { + log.error("Error fetching personalized recommendations", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + /** + * Helper: Compute recommendations via recommenderService (real-time, no caching) + */ + private List computePersonalizedRecommendations(List seedBooks, Integer topK) { + try { + // Call recommenderService (collaborative filtering) + GetPersonalizedRecommendationsRequest request = GetPersonalizedRecommendationsRequest.builder() + .userSeedBooks(seedBooks) + .topK(Math.min(topK, 50)) // Max 50 + .build(); + + ResponseEntity> response = + recommenderServiceClient.getPersonalizedRecommendations(request); + + if (response.getStatusCode() != HttpStatus.OK || response.getBody() == null) { + return Collections.emptyList(); + } + + List recommendations = response.getBody(); + + // Build map of seed books for explanation enrichment + Map seedBooksMap = new HashMap<>(); + for (Integer seedBookId : seedBooks) { + Optional seedBook = service.getById(seedBookId); + if (seedBook.isPresent()) { + seedBooksMap.put(seedBookId, getMapper().entityToResponse(seedBook.get())); + } + } + + List result = new ArrayList<>(); + for (PersonalizedRecommendationResponse rec : recommendations) { + try { + Optional book = service.getById(rec.getBookId()); + if (book.isPresent() && !book.get().isArchived()) { + BookResponse bookResponse = getMapper().entityToResponse(book.get()); + + // Preserve and enrich explanation from recommenderService + if (rec.getExplanation() != null) { + enrichExplanationWithBookTitles(bookResponse, rec.getExplanation(), seedBooksMap); + } + + result.add(bookResponse); + } + } catch (Exception e) { + log.warn("Failed to fetch book: {}", rec.getBookId()); + } + } + + return result; + + } catch (Exception e) { + log.error("Error computing personalized recommendations", e); + return Collections.emptyList(); + } + } + + /** + * Helper: Get popular books (for new/unauthenticated users) + */ + private List getPopularBooks(Integer topK) { + try { + // Get popular books: sorted by average rating and number of reviews + Pageable pageable = PageRequest.of(0, topK, Sort.by("averageRating").descending() + .and(Sort.by("totalReviews").descending())); + + return service.getAll(pageable, null).getContent().stream() + .map(getMapper()::entityToResponse) + .collect(Collectors.toList()); + } catch (Exception e) { + log.error("Error fetching popular books", e); + return Collections.emptyList(); + } + } + + /** + * Helper: Enrich personalized recommendation explanation with book titles + */ + private void enrichExplanationWithBookTitles( + BookResponse bookResponse, + ExplanationDetails explanation, + Map seedBooksMap) { + + if (explanation == null || explanation.getTopContributors() == null || explanation.getTopContributors().isEmpty()) { + return; + } + + // Try to get the first contributor (seed book ID) and replace with title + String firstContributor = explanation.getTopContributors().get(0); + + try { + Integer seedBookId = Integer.parseInt(firstContributor); + BookResponse seedBook = seedBooksMap.get(seedBookId); + + if (seedBook != null) { + // Update primary reason with actual book title + String updatedReason = "Based on your review of '" + seedBook.getTitle() + "'"; + explanation.setPrimaryReason(updatedReason); + + // Replace contributor ID with book title + explanation.setTopContributors(Collections.singletonList(seedBook.getTitle())); + + // Set explanation on book response + bookResponse.setExplanation(explanation); + } + } catch (NumberFormatException e) { + // Not a numeric ID, keep as-is + bookResponse.setExplanation(explanation); + } + } } diff --git a/bookService/src/main/java/org/library/bookservice/datagen/TestDataGenerator.java b/bookService/src/main/java/org/library/bookservice/datagen/TestDataGenerator.java index 73ba800..0cdfba2 100644 --- a/bookService/src/main/java/org/library/bookservice/datagen/TestDataGenerator.java +++ b/bookService/src/main/java/org/library/bookservice/datagen/TestDataGenerator.java @@ -14,6 +14,7 @@ import java.io.IOException; import java.io.InputStream; import java.nio.file.Paths; +import java.text.Normalizer; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -85,7 +86,10 @@ public void initializeDbWithTestData() { Map publisherMap = publishers.stream() .collect(Collectors.toMap(Publisher::getName, Function.identity())); Map authorMap = authors.stream() - .collect(Collectors.toMap(Author::getName, Function.identity())); + .collect(Collectors.toMap( + author -> normalizeString(author.getName()), + Function.identity() + )); List bookDtos = loadData(booksFile, new TypeReference<>() { }); @@ -112,8 +116,8 @@ public void initializeDbWithTestData() { log.warn("Publisher not found: {}", dto.getPublisher()); } - if (authorMap.containsKey(dto.getAuthor())) { - book.setAuthor(authorMap.get(dto.getAuthor())); + if (authorMap.containsKey(normalizeString(dto.getAuthor()))) { + book.setAuthor(authorMap.get(normalizeString(dto.getAuthor()))); } else { log.warn("Author not found: {}", dto.getAuthor()); } @@ -151,4 +155,15 @@ private List loadData(String fileName, TypeReference> typeReferen return objectMapper.readValue(inputStream, typeReference); } } + + /** + * Normalize Unicode strings to NFC form to handle special characters like ë, é, etc. + * Prevents lookup failures due to Unicode normalization differences. + */ + private String normalizeString(String input) { + if (input == null) { + return null; + } + return Normalizer.normalize(input, Normalizer.Form.NFC); + } } \ No newline at end of file diff --git a/bookService/src/main/java/org/library/bookservice/dto/book/BookResponse.java b/bookService/src/main/java/org/library/bookservice/dto/book/BookResponse.java index 72ecfe1..4843dd0 100644 --- a/bookService/src/main/java/org/library/bookservice/dto/book/BookResponse.java +++ b/bookService/src/main/java/org/library/bookservice/dto/book/BookResponse.java @@ -8,6 +8,7 @@ import org.library.bookservice.dto.category.CategoryResponse; import org.library.bookservice.dto.genre.GenreResponse; import org.library.bookservice.dto.publisher.PublisherResponse; +import org.library.bookservice.dto.recommender.ExplanationDetails; import java.math.BigDecimal; import java.time.LocalDate; @@ -31,4 +32,10 @@ public class BookResponse extends AbstractResponse { private List bookGenres; private Double averageRating; private Integer totalReviews; + + /** + * Optional: Explanation for recommendation (for similar books / personalized recommendations) + */ + @Builder.Default + private ExplanationDetails explanation = null; } diff --git a/bookService/src/main/java/org/library/bookservice/dto/recommender/ExplanationDetails.java b/bookService/src/main/java/org/library/bookservice/dto/recommender/ExplanationDetails.java new file mode 100644 index 0000000..bebbcb3 --- /dev/null +++ b/bookService/src/main/java/org/library/bookservice/dto/recommender/ExplanationDetails.java @@ -0,0 +1,44 @@ +package org.library.bookservice.dto.recommender; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * Explanation of why a book was recommended + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ExplanationDetails { + + /** + * Main reason text (e.g., "Content similarity: 5 matching topics") + */ + private String primaryReason; + + /** + * Type of explanation (TF_IDF_MATCH, COLLABORATIVE_FILTER) + */ + private String reasonType; + + /** + * Top contributing factors (e.g., keywords for TF-IDF, book IDs for collaborative) + */ + private List topContributors; + + /** + * Confidence score (0.0 to 1.0) + */ + private Double confidenceScore; + + /** + * Additional context details + */ + private Map details; +} diff --git a/bookService/src/main/java/org/library/bookservice/dto/recommender/GetPersonalizedRecommendationsRequest.java b/bookService/src/main/java/org/library/bookservice/dto/recommender/GetPersonalizedRecommendationsRequest.java new file mode 100644 index 0000000..b3f1cba --- /dev/null +++ b/bookService/src/main/java/org/library/bookservice/dto/recommender/GetPersonalizedRecommendationsRequest.java @@ -0,0 +1,20 @@ +package org.library.bookservice.dto.recommender; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Request DTO for getting personalized recommendations + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class GetPersonalizedRecommendationsRequest { + private List userSeedBooks; + private Integer topK; +} diff --git a/bookService/src/main/java/org/library/bookservice/dto/recommender/GetSimilarBooksRequest.java b/bookService/src/main/java/org/library/bookservice/dto/recommender/GetSimilarBooksRequest.java new file mode 100644 index 0000000..5ec3163 --- /dev/null +++ b/bookService/src/main/java/org/library/bookservice/dto/recommender/GetSimilarBooksRequest.java @@ -0,0 +1,18 @@ +package org.library.bookservice.dto.recommender; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Request DTO for getting similar books + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class GetSimilarBooksRequest { + private Integer bookId; + private Integer topK; +} diff --git a/bookService/src/main/java/org/library/bookservice/dto/recommender/HealthCheckResponse.java b/bookService/src/main/java/org/library/bookservice/dto/recommender/HealthCheckResponse.java new file mode 100644 index 0000000..1da18d7 --- /dev/null +++ b/bookService/src/main/java/org/library/bookservice/dto/recommender/HealthCheckResponse.java @@ -0,0 +1,21 @@ +package org.library.bookservice.dto.recommender; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * Health check response from recommenderService + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class HealthCheckResponse { + private String status; + private Boolean modelsLoaded; + private Map modelMetadata; +} diff --git a/bookService/src/main/java/org/library/bookservice/dto/recommender/PersonalizedRecommendationResponse.java b/bookService/src/main/java/org/library/bookservice/dto/recommender/PersonalizedRecommendationResponse.java new file mode 100644 index 0000000..39bf2a6 --- /dev/null +++ b/bookService/src/main/java/org/library/bookservice/dto/recommender/PersonalizedRecommendationResponse.java @@ -0,0 +1,22 @@ +package org.library.bookservice.dto.recommender; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * DTO for personalized recommendation response from recommenderService + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PersonalizedRecommendationResponse { + private Integer bookId; + private Float score; + private ExplanationDetails explanation; + private List seedBookIds; +} diff --git a/bookService/src/main/java/org/library/bookservice/dto/recommender/SimilarBookResponse.java b/bookService/src/main/java/org/library/bookservice/dto/recommender/SimilarBookResponse.java new file mode 100644 index 0000000..594fa59 --- /dev/null +++ b/bookService/src/main/java/org/library/bookservice/dto/recommender/SimilarBookResponse.java @@ -0,0 +1,19 @@ +package org.library.bookservice.dto.recommender; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * DTO for similar book response from recommenderService + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SimilarBookResponse { + private Integer bookId; + private Float similarityScore; + private ExplanationDetails explanation; +} diff --git a/bookService/src/main/java/org/library/bookservice/dto/review/ReviewResponse.java b/bookService/src/main/java/org/library/bookservice/dto/review/ReviewResponse.java new file mode 100644 index 0000000..2140ff4 --- /dev/null +++ b/bookService/src/main/java/org/library/bookservice/dto/review/ReviewResponse.java @@ -0,0 +1,31 @@ +package org.library.bookservice.dto.review; + +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +@Data +@EqualsAndHashCode(callSuper=false) +@Builder +public class ReviewResponse { + + private String id; + + private String userId; + + private String firstName; + + private String lastName; + + private String avatarUrl; + + private Integer bookId; + + private LocalDateTime createdAt; + + private Integer rating; + + private String text; +} \ No newline at end of file diff --git a/bookService/src/main/resources/application-dev.yml b/bookService/src/main/resources/application-dev.yml index 7784703..9bb5e2f 100644 --- a/bookService/src/main/resources/application-dev.yml +++ b/bookService/src/main/resources/application-dev.yml @@ -10,12 +10,12 @@ spring: properties: schema: registry: - url: http://localhost:8082 + url: http://localhost:8085 producer: properties: schema: registry: - url: http://localhost:8082 + url: http://localhost:8085 security: oauth2: @@ -27,9 +27,25 @@ spring: gateway: url: http://localhost:9000 + review-service: url: http://localhost:8081 +recommender-service: + url: http://localhost:8082 + +feign: + client: + config: + recommender-service: + connectTimeout: 5000 + readTimeout: 10000 + loggerLevel: FULL + review-service: + connectTimeout: 5000 + readTimeout: 5000 + loggerLevel: FULL + data: book: images: diff --git a/bookService/src/main/resources/application-prod.yml b/bookService/src/main/resources/application-prod.yml index 7234aa0..f100b0f 100644 --- a/bookService/src/main/resources/application-prod.yml +++ b/bookService/src/main/resources/application-prod.yml @@ -10,12 +10,12 @@ spring: properties: schema: registry: - url: http://schema-registry:8082 + url: http://schema-registry:8085 producer: properties: schema: registry: - url: http://schema-registry:8082 + url: http://schema-registry:8085 security: oauth2: @@ -23,11 +23,28 @@ spring: jwt: issuer-uri: http://keycloak:8080/realms/e-library token-uri: http://keycloak:8080/realms/e-library/protocol/openid-connect/token + gateway: url: http://api-gateway:9000 + review-service: url: http://review-service:8081 +recommender-service: + url: http://recommender-service:8082 + +feign: + client: + config: + recommender-service: + connectTimeout: 5000 + readTimeout: 10000 + loggerLevel: FULL + review-service: + connectTimeout: 5000 + readTimeout: 5000 + loggerLevel: FULL + data: book: images: /app/data/book_images/ diff --git a/data/seeding/books.json b/data/seeding/books.json index d96d239..cb826cd 100644 --- a/data/seeding/books.json +++ b/data/seeding/books.json @@ -571,7 +571,7 @@ "title": "The Invisible Man", "isbn": "9780812504675", "description": "This book is the story of Griffin, a scientist who creates a serum to render himself invisible, and his descent into madness that follows....", - "releaseDate": "0-05-21", + "releaseDate": "1897-05-21", "price": 26.54, "imageKey": "9780812504675.jpg", "category": "Non-Fiction", @@ -956,7 +956,7 @@ "category": "Non-Fiction", "publisher": "Open Library Edition", "author": "William Shakespeare", - "genres": [] + "genres": ["Classic", "Drama"] }, { "title": "Les Robots", @@ -997,7 +997,7 @@ "category": "Technology", "publisher": "Open Library Edition", "author": "John R. Levine", - "genres": [] + "genres": ["Technology", "Education"] }, { "title": "Stormbreaker", @@ -1024,7 +1024,7 @@ "category": "Technology", "publisher": "Open Library Edition", "author": "Scott Mueller", - "genres": [] + "genres": ["Technology", "Education"] }, { "title": "Artificial intelligence", @@ -1036,7 +1036,7 @@ "category": "Technology", "publisher": "Open Library Edition", "author": "Stuart J. Russell", - "genres": [] + "genres": ["Technology", "Science Fiction"] }, { "title": "Computer Concepts", @@ -1048,7 +1048,7 @@ "category": "Technology", "publisher": "Open Library Edition", "author": "June Jamrich Parsons", - "genres": [] + "genres": ["Technology", "Education"] }, { "title": "Mona Lisa Overdrive", @@ -1074,7 +1074,7 @@ "category": "Technology", "publisher": "Open Library Edition", "author": "Paul J. Deitel", - "genres": [] + "genres": ["Technology", "Education"] }, { "title": "Julius Caesar", @@ -1100,7 +1100,7 @@ "category": "Biography", "publisher": "Open Library Edition", "author": "Frederick Douglass", - "genres": [] + "genres": ["Classic", "Education"] }, { "title": "Twelve years a slave", @@ -1112,7 +1112,7 @@ "category": "Biography", "publisher": "Open Library Edition", "author": "Solomon Northup", - "genres": [] + "genres": ["Classic", "Education"] }, { "title": "My Ántonia", @@ -1124,7 +1124,7 @@ "category": "Biography", "publisher": "Open Library Edition", "author": "Willa Cather", - "genres": [] + "genres": ["Classic", "Romance"] }, { "title": "The Canterbury Tales", @@ -1136,7 +1136,7 @@ "category": "Biography", "publisher": "Open Library Edition", "author": "Geoffrey Chaucer", - "genres": [] + "genres": ["Classic", "Adventure"] }, { "title": "Sonnets", @@ -1148,7 +1148,7 @@ "category": "Biography", "publisher": "Open Library Edition", "author": "William Shakespeare", - "genres": [] + "genres": ["Classic", "Drama"] }, { "title": "An autobiography", @@ -1160,7 +1160,7 @@ "category": "Biography", "publisher": "Open Library Edition", "author": "Mohandas Karamchand Gandhi", - "genres": [] + "genres": ["Classic", "Psychology"] }, { "title": "The Secret Garden", @@ -1187,7 +1187,7 @@ "category": "Non-Fiction", "publisher": "Open Library Edition", "author": "Gustave Flaubert", - "genres": [] + "genres": ["Classic", "Romance"] }, { "title": "The Art of War", @@ -1199,7 +1199,7 @@ "category": "Non-Fiction", "publisher": "Open Library Edition", "author": "孙武", - "genres": [] + "genres": ["Education", "Psychology"] }, { "title": "Записки изъ подполья", @@ -1211,7 +1211,7 @@ "category": "Non-Fiction", "publisher": "Open Library Edition", "author": "Фёдор Михайлович Достоевский", - "genres": [] + "genres": ["Classic", "Psychology"] }, { "title": "πολιτεία", @@ -1237,7 +1237,7 @@ "category": "Non-Fiction", "publisher": "Open Library Edition", "author": "老子", - "genres": [] + "genres": ["Education", "Psychology"] }, { "title": "Also sprach Zarathustra", @@ -1263,7 +1263,7 @@ "category": "Non-Fiction", "publisher": "Open Library Edition", "author": "William Shakespeare", - "genres": [] + "genres": ["Classic", "Drama"] }, { "title": "Two years before the mast", @@ -1275,7 +1275,7 @@ "category": "Biography", "publisher": "Open Library Edition", "author": "Richard Henry Dana", - "genres": [] + "genres": ["Adventure", "Classic"] }, { "title": "Principles of Anatomy and Physiology", diff --git a/data/seeding/genres.json b/data/seeding/genres.json index cb60890..99b02e1 100644 --- a/data/seeding/genres.json +++ b/data/seeding/genres.json @@ -10,5 +10,7 @@ { "name": "Adventure", "description": "Stories featuring exciting and dangerous journeys." }, { "name": "Self-Help", "description": "Books written to instruct on solving personal problems." }, { "name": "Psychology", "description": "Study of mind and behavior." }, - { "name": "Education", "description": "Textbooks and learning materials." } + { "name": "Education", "description": "Textbooks and learning materials." }, + { "name": "Technology", "description": "Technical topics and computer science." }, + { "name": "Drama", "description": "Theatrical performances and dramatic works." } ] \ No newline at end of file diff --git a/data/seeding/publishers.json b/data/seeding/publishers.json index 1081372..f478ba2 100644 --- a/data/seeding/publishers.json +++ b/data/seeding/publishers.json @@ -10,5 +10,6 @@ { "name": "Little, Brown and Company", "address": "Boston, USA" }, { "name": "Random House", "address": "New York, USA" }, { "name": "Prentice Hall", "address": "New Jersey, USA" }, - { "name": "Celadon Books", "address": "New York, USA" } + { "name": "Celadon Books", "address": "New York, USA" }, + { "name": "Open Library Edition", "address": "New York, USA" } ] \ No newline at end of file diff --git a/data/seeding/reviews.json b/data/seeding/reviews.json index 4a5ece3..b250e28 100644 --- a/data/seeding/reviews.json +++ b/data/seeding/reviews.json @@ -1,6 +1,6 @@ [ { - "userId": "b61d64ba-aa87-45ec-8f61-a422afab1705", + "userId": "fiction-user-2", "firstName": "Avery", "lastName": "Thompson", "avatarUrl": null, @@ -10,7 +10,7 @@ "text": "The plot of '1984' has strong Science Fiction, Dystopian moments; the characters are well-developed and the pacing is solid." }, { - "userId": "0db99dcb-a611-482a-b56d-c99e213f0362", + "userId": "mixed-reader-3", "firstName": "Reese", "lastName": "Martin", "avatarUrl": "https://i.pravatar.cc/150?u=0db99dcb-a611-482a-b56d-c99e213f0362", @@ -20,7 +20,7 @@ "text": "Nice story, enjoyed it." }, { - "userId": "657131b2-f12e-4ed7-a0e3-3a95adac8b68", + "userId": "mixed-reader-3", "firstName": "Rowan", "lastName": "Garcia", "avatarUrl": "https://i.pravatar.cc/150?u=657131b2-f12e-4ed7-a0e3-3a95adac8b68", @@ -30,7 +30,7 @@ "text": "Read it in one weekend. Good enough!" }, { - "userId": "48b0ab56-07e0-48b9-804d-22d05a8495f8", + "userId": "fiction-user-1", "firstName": "Quinn", "lastName": "White", "avatarUrl": "https://i.pravatar.cc/150?u=48b0ab56-07e0-48b9-804d-22d05a8495f8", @@ -40,7 +40,7 @@ "text": "George Orwell really shines in '1984' with chilling, prophecy, about. This fiction novel uses Dystopian, Science Fiction elements and themes from the description with clear author voice." }, { - "userId": "754fb9f9-e48e-47f1-80b0-0463cc775145", + "userId": "fiction-user-4", "firstName": "Jordan", "lastName": "Mitchell", "avatarUrl": null, @@ -50,7 +50,7 @@ "text": "This book has good atmosphere, and the Adventure, Fantasy influence is evident. It reminded me of the description's highlights." }, { - "userId": "6c2f49b5-61ed-4f09-9721-7bc0505d3ffd", + "userId": "mixed-reader-4", "firstName": "Aiden", "lastName": "Allen", "avatarUrl": "https://i.pravatar.cc/150?u=6c2f49b5-61ed-4f09-9721-7bc0505d3ffd", @@ -60,7 +60,7 @@ "text": "Good book, very nice." }, { - "userId": "d1c821fd-b1e8-47d2-a1ce-6a8effa0821f", + "userId": "fiction-user-4", "firstName": "Rowan", "lastName": "Garcia", "avatarUrl": "https://i.pravatar.cc/150?u=d1c821fd-b1e8-47d2-a1ce-6a8effa0821f", @@ -70,7 +70,7 @@ "text": "I enjoyed the fiction style, especially the parts that echo Fantasy, Adventure. Not a perfect read but very worthwhile." }, { - "userId": "40237288-ab69-4a2b-a8ba-b0638f9636bf", + "userId": "fiction-user-1", "firstName": "Jamie", "lastName": "Taylor", "avatarUrl": "https://i.pravatar.cc/150?u=40237288-ab69-4a2b-a8ba-b0638f9636bf", @@ -80,7 +80,7 @@ "text": "J.K. Rowling really shines in 'Harry Potter and the Sorcerer's Stone' with first, book, Harry. This fiction novel uses Fantasy, Adventure elements and themes from the description with clear author voice." }, { - "userId": "4d063298-3f39-47e4-8773-2ed8b8cf2b84", + "userId": "fiction-user-1", "firstName": "Emerson", "lastName": "Lee", "avatarUrl": "https://i.pravatar.cc/150?u=4d063298-3f39-47e4-8773-2ed8b8cf2b84", @@ -90,7 +90,7 @@ "text": "The plot of 'The Shining' has strong Thriller, Horror moments; the characters are well-developed and the pacing is solid." }, { - "userId": "65d4d3bf-c41d-4f8c-9df1-235a61e0a9da", + "userId": "fiction-user-3", "firstName": "Taylor", "lastName": "Brown", "avatarUrl": "https://i.pravatar.cc/150?u=65d4d3bf-c41d-4f8c-9df1-235a61e0a9da", @@ -100,7 +100,7 @@ "text": "Stephen King really shines in 'The Shining' with Jack, Torrance's, Overlook. This fiction novel uses Horror, Thriller elements and themes from the description with clear author voice." }, { - "userId": "3f1167af-d136-4341-b243-46c2ad8ee436", + "userId": "fiction-user-5", "firstName": "Avery", "lastName": "Thompson", "avatarUrl": "https://i.pravatar.cc/150?u=3f1167af-d136-4341-b243-46c2ad8ee436", @@ -110,7 +110,7 @@ "text": "Okay read, nothing special." }, { - "userId": "f0dfcabc-d1cb-4c55-a0ae-631b517ebb4b", + "userId": "fiction-user-2", "firstName": "Avery", "lastName": "Thompson", "avatarUrl": "https://i.pravatar.cc/150?u=f0dfcabc-d1cb-4c55-a0ae-631b517ebb4b", @@ -120,7 +120,7 @@ "text": "Nice story, enjoyed it." }, { - "userId": "64f7b2bf-4e93-4e1d-a9be-6f3e400b52fa", + "userId": "fiction-user-1", "firstName": "Hayden", "lastName": "Jackson", "avatarUrl": null, @@ -130,7 +130,7 @@ "text": "Average book, use your judgment." }, { - "userId": "4f5d2a5e-8179-42a2-8cda-18d56374ece3", + "userId": "fiction-user-4", "firstName": "Marley", "lastName": "Nelson", "avatarUrl": null, @@ -140,7 +140,7 @@ "text": "Jane Austen really shines in 'Pride and Prejudice' with romantic, novel, manners. This fiction novel uses Romance, Classic elements and themes from the description with clear author voice." }, { - "userId": "15ad8fbf-44c9-4ba1-a2ad-5a8c5c8209e1", + "userId": "fiction-user-4", "firstName": "Cameron", "lastName": "Anderson", "avatarUrl": "https://i.pravatar.cc/150?u=15ad8fbf-44c9-4ba1-a2ad-5a8c5c8209e1", @@ -150,7 +150,7 @@ "text": "The plot of 'Pride and Prejudice' has strong Classic, Romance moments; the characters are well-developed and the pacing is solid." }, { - "userId": "a59dad9a-137a-4fab-a760-35a2507228dd", + "userId": "fiction-user-3", "firstName": "Blake", "lastName": "Walker", "avatarUrl": null, @@ -160,7 +160,7 @@ "text": "The plot of 'Pride and Prejudice' has strong Classic, Romance moments; the characters are well-developed and the pacing is solid." }, { - "userId": "f948f431-1149-4812-8ecd-090e77af2134", + "userId": "fiction-user-3", "firstName": "Alex", "lastName": "Smith", "avatarUrl": "https://i.pravatar.cc/150?u=f948f431-1149-4812-8ecd-090e77af2134", @@ -170,7 +170,7 @@ "text": "I read this on vacation and it was fine." }, { - "userId": "ed25a0e1-96a2-4111-8df5-d975aecc3c3b", + "userId": "fiction-user-3", "firstName": "Devon", "lastName": "Harris", "avatarUrl": "https://i.pravatar.cc/150?u=ed25a0e1-96a2-4111-8df5-d975aecc3c3b", @@ -180,7 +180,7 @@ "text": "Okay read, nothing special." }, { - "userId": "80d1764d-6a48-40b9-b31e-4a1cc1470b0a", + "userId": "mixed-reader-1", "firstName": "Skyler", "lastName": "Martinez", "avatarUrl": null, @@ -190,7 +190,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "b5568db0-bfa2-4830-9ff3-05189a0e572c", + "userId": "mixed-reader-3", "firstName": "Shawn", "lastName": "Adams", "avatarUrl": "https://i.pravatar.cc/150?u=b5568db0-bfa2-4830-9ff3-05189a0e572c", @@ -200,7 +200,7 @@ "text": "Read it in one weekend. Good enough!" }, { - "userId": "831bf6a4-b2b7-4086-8d3b-f6452aa2f821", + "userId": "mixed-reader-4", "firstName": "Rowan", "lastName": "Green", "avatarUrl": "https://i.pravatar.cc/150?u=831bf6a4-b2b7-4086-8d3b-f6452aa2f821", @@ -210,7 +210,7 @@ "text": "This book has good atmosphere, and the Romance, Classic influence is evident. It reminded me of the description's highlights." }, { - "userId": "8c92edc2-9ccd-4784-99d1-71487aaf7bdf", + "userId": "fiction-user-2", "firstName": "Dakota", "lastName": "Turner", "avatarUrl": "https://i.pravatar.cc/150?u=8c92edc2-9ccd-4784-99d1-71487aaf7bdf", @@ -220,7 +220,7 @@ "text": "F. Scott Fitzgerald really shines in 'The Great Gatsby' with story, mysteriously, wealthy. This fiction novel uses Classic, Romance elements and themes from the description with clear author voice." }, { - "userId": "e7002708-a229-4e75-9fc1-510cbccda240", + "userId": "fiction-user-5", "firstName": "Alex", "lastName": "Smith", "avatarUrl": null, @@ -230,7 +230,7 @@ "text": "Not my genre, but still pretty good." }, { - "userId": "41b07270-9323-45c2-a74f-e804ac5e4436", + "userId": "fiction-user-2", "firstName": "Casey", "lastName": "Wilson", "avatarUrl": "https://i.pravatar.cc/150?u=41b07270-9323-45c2-a74f-e804ac5e4436", @@ -240,7 +240,7 @@ "text": "J.R.R. Tolkien really shines in 'The Hobbit' with timeless, classic, all-age. This fiction novel uses Fantasy, Adventure elements and themes from the description with clear author voice." }, { - "userId": "fea3fb4a-4827-4d11-adea-2e73b3940c0e", + "userId": "fiction-user-3", "firstName": "Skyler", "lastName": "Martinez", "avatarUrl": null, @@ -250,7 +250,7 @@ "text": "The plot of 'The Hobbit' has strong Fantasy, Adventure moments; the characters are well-developed and the pacing is solid." }, { - "userId": "f0b3056d-feac-4dfc-ace9-2d791913fe68", + "userId": "fiction-user-5", "firstName": "Parker", "lastName": "Thomas", "avatarUrl": "https://i.pravatar.cc/150?u=f0b3056d-feac-4dfc-ace9-2d791913fe68", @@ -260,7 +260,7 @@ "text": "I enjoyed the fiction style, especially the parts that echo Adventure, Fantasy. Not a perfect read but very worthwhile." }, { - "userId": "2f4c6297-2f11-4af6-83c4-f9f64baa6575", + "userId": "fiction-user-3", "firstName": "Blake", "lastName": "Walker", "avatarUrl": "https://i.pravatar.cc/150?u=2f4c6297-2f11-4af6-83c4-f9f64baa6575", @@ -270,7 +270,7 @@ "text": "I enjoyed the fiction style, especially the parts that echo Thriller, Classic. Not a perfect read but very worthwhile." }, { - "userId": "1a9184b6-b701-4ccc-8d91-2f74e49e2095", + "userId": "fiction-user-3", "firstName": "Reese", "lastName": "Martin", "avatarUrl": "https://i.pravatar.cc/150?u=1a9184b6-b701-4ccc-8d91-2f74e49e2095", @@ -280,7 +280,7 @@ "text": "The plot of 'To Kill a Mockingbird' has strong Thriller, Classic moments; the characters are well-developed and the pacing is solid." }, { - "userId": "51727274-ae9d-46b9-ae3e-717a707f4cd3", + "userId": "fiction-user-5", "firstName": "Reagan", "lastName": "Clark", "avatarUrl": "https://i.pravatar.cc/150?u=51727274-ae9d-46b9-ae3e-717a707f4cd3", @@ -290,7 +290,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "2bbf28fc-e83b-4f8d-ade9-51d45b141872", + "userId": "fiction-user-5", "firstName": "Jordan", "lastName": "Mitchell", "avatarUrl": null, @@ -300,7 +300,7 @@ "text": "Harper Lee really shines in 'To Kill a Mockingbird' with novel, about, serious. This fiction novel uses Classic, Thriller elements and themes from the description with clear author voice." }, { - "userId": "2719da6a-6d5f-40af-a606-a7f6ea1ea934", + "userId": "fiction-user-1", "firstName": "Brooklyn", "lastName": "Young", "avatarUrl": null, @@ -310,7 +310,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "eba913fc-0394-47d5-bce3-0005d65bcd75", + "userId": "fiction-user-5", "firstName": "Jordan", "lastName": "Mitchell", "avatarUrl": "https://i.pravatar.cc/150?u=eba913fc-0394-47d5-bce3-0005d65bcd75", @@ -320,7 +320,7 @@ "text": "Frank Herbert really shines in 'Dune' with desert, planet, Arrakis. This fiction novel uses Science Fiction, Adventure elements and themes from the description with clear author voice." }, { - "userId": "f60cb897-e309-45e0-bdb7-35abd116a7a1", + "userId": "mixed-reader-2", "firstName": "Dallas", "lastName": "Rodriguez", "avatarUrl": null, @@ -330,7 +330,7 @@ "text": "I read this on vacation and it was fine." }, { - "userId": "b0a51c5f-cec7-4e4d-b4e1-1e46a44571b8", + "userId": "fiction-user-4", "firstName": "Dylan", "lastName": "Carter", "avatarUrl": null, @@ -340,7 +340,7 @@ "text": "I enjoyed the fiction style, especially the parts that echo Science Fiction, Adventure. Not a perfect read but very worthwhile." }, { - "userId": "20058aac-c7e8-4cc2-8929-f447d2faabe1", + "userId": "fiction-user-5", "firstName": "Finley", "lastName": "Wright", "avatarUrl": "https://i.pravatar.cc/150?u=20058aac-c7e8-4cc2-8929-f447d2faabe1", @@ -350,7 +350,7 @@ "text": "The plot of 'The Adventures of Sherlock Holmes' has strong Mystery, Classic moments; the characters are well-developed and the pacing is solid." }, { - "userId": "eb3e7405-86ae-4ade-9b72-2df54fad0692", + "userId": "fiction-user-1", "firstName": "Phoenix", "lastName": "Robinson", "avatarUrl": null, @@ -360,7 +360,7 @@ "text": "Cool book, maybe a bit long, but still worth it." }, { - "userId": "efbad1d6-4283-4ff9-b9de-30e0e51342a7", + "userId": "fiction-user-4", "firstName": "Drew", "lastName": "King", "avatarUrl": null, @@ -370,7 +370,7 @@ "text": "Read it in one weekend. Good enough!" }, { - "userId": "bb14ba2d-c23d-48fd-830c-c5c80ec2afd7", + "userId": "fiction-user-4", "firstName": "Emerson", "lastName": "Lee", "avatarUrl": "https://i.pravatar.cc/150?u=bb14ba2d-c23d-48fd-830c-c5c80ec2afd7", @@ -380,7 +380,7 @@ "text": "Arthur Conan Doyle really shines in 'The Adventures of Sherlock Holmes' with collection, twelve, short. This fiction novel uses Mystery, Classic elements and themes from the description with clear author voice." }, { - "userId": "bcdc8b56-344a-4588-82ee-fccdb36d8ab8", + "userId": "fiction-user-3", "firstName": "Peyton", "lastName": "Phillips", "avatarUrl": "https://i.pravatar.cc/150?u=bcdc8b56-344a-4588-82ee-fccdb36d8ab8", @@ -390,7 +390,7 @@ "text": "Nice story, enjoyed it." }, { - "userId": "d903148a-00e7-4f3e-975d-e215e0f74e11", + "userId": "fiction-user-5", "firstName": "Jordan", "lastName": "Mitchell", "avatarUrl": null, @@ -400,7 +400,7 @@ "text": "I enjoyed the fiction style, especially the parts that echo Classic. Not a perfect read but very worthwhile." }, { - "userId": "a86ef96e-ba95-4015-bb2d-d8e11188b0d2", + "userId": "fiction-user-3", "firstName": "Parker", "lastName": "Thomas", "avatarUrl": null, @@ -410,7 +410,7 @@ "text": "J.D. Salinger really shines in 'The Catcher in the Rye' with story, about, adolescent. This fiction novel uses Classic elements and themes from the description with clear author voice." }, { - "userId": "ce6fa492-43f5-4bb0-8d4f-d9a8a257b93d", + "userId": "fiction-user-5", "firstName": "Hayden", "lastName": "Jackson", "avatarUrl": null, @@ -420,7 +420,7 @@ "text": "Okay read, nothing special." }, { - "userId": "cbffce99-a4ab-43f4-bd24-58c183e449fc", + "userId": "fiction-user-5", "firstName": "Blake", "lastName": "Walker", "avatarUrl": null, @@ -430,7 +430,7 @@ "text": "I enjoyed the fiction style, especially the parts that echo Classic. Not a perfect read but very worthwhile." }, { - "userId": "1bcbaea8-64b4-4822-9683-ad062695a582", + "userId": "non-fiction-user-4", "firstName": "Cameron", "lastName": "Anderson", "avatarUrl": null, @@ -440,7 +440,7 @@ "text": "This book has good atmosphere, and the Education, Psychology influence is evident. It reminded me of the description's highlights." }, { - "userId": "5f798a49-53b9-4950-b6d3-085366da844b", + "userId": "non-fiction-user-2", "firstName": "Taylor", "lastName": "Brown", "avatarUrl": "https://i.pravatar.cc/150?u=5f798a49-53b9-4950-b6d3-085366da844b", @@ -450,7 +450,7 @@ "text": "Yuval Noah Harari really shines in 'Sapiens: A Brief History of Humankind' with history, humankind, from. This non-fiction novel uses Education, Psychology elements and themes from the description with clear author voice." }, { - "userId": "9d761d6e-89d3-4e36-a52b-740166abf035", + "userId": "non-fiction-user-5", "firstName": "Kai", "lastName": "Lopez", "avatarUrl": null, @@ -460,7 +460,7 @@ "text": "The plot of 'Sapiens: A Brief History of Humankind' has strong Psychology, Education moments; the characters are well-developed and the pacing is solid." }, { - "userId": "b3d69dc2-33d2-42ac-b11f-3b7c68da78b4", + "userId": "non-fiction-user-1", "firstName": "Taylor", "lastName": "Brown", "avatarUrl": "https://i.pravatar.cc/150?u=b3d69dc2-33d2-42ac-b11f-3b7c68da78b4", @@ -470,7 +470,7 @@ "text": "Okay read, nothing special." }, { - "userId": "08f7ccfa-097a-43ea-aa24-277d9d6c65ed", + "userId": "non-fiction-user-1", "firstName": "Jamie", "lastName": "Taylor", "avatarUrl": "https://i.pravatar.cc/150?u=08f7ccfa-097a-43ea-aa24-277d9d6c65ed", @@ -480,7 +480,7 @@ "text": "Okay read, nothing special." }, { - "userId": "455604b3-fd86-46b1-9636-a59e0aeba0d5", + "userId": "non-fiction-user-2", "firstName": "Brooklyn", "lastName": "Young", "avatarUrl": "https://i.pravatar.cc/150?u=455604b3-fd86-46b1-9636-a59e0aeba0d5", @@ -490,7 +490,7 @@ "text": "I enjoyed the non-fiction style, especially the parts that echo Self-Help, Psychology. Not a perfect read but very worthwhile." }, { - "userId": "8fc9d2a6-7d10-4cc3-93c6-f9be3361aa2d", + "userId": "non-fiction-user-5", "firstName": "Skyler", "lastName": "Scott", "avatarUrl": "https://i.pravatar.cc/150?u=8fc9d2a6-7d10-4cc3-93c6-f9be3361aa2d", @@ -500,7 +500,7 @@ "text": "I read this on vacation and it was fine." }, { - "userId": "ee33053f-640e-4701-9927-00610c9d0480", + "userId": "non-fiction-user-1", "firstName": "Reese", "lastName": "Martin", "avatarUrl": "https://i.pravatar.cc/150?u=ee33053f-640e-4701-9927-00610c9d0480", @@ -510,7 +510,7 @@ "text": "James Clear really shines in 'Atomic Habits' with easy, proven, build. This non-fiction novel uses Self-Help, Psychology elements and themes from the description with clear author voice." }, { - "userId": "7881cf5a-3a29-4b5d-84ea-a649df6f7292", + "userId": "non-fiction-user-3", "firstName": "Reagan", "lastName": "Clark", "avatarUrl": "https://i.pravatar.cc/150?u=7881cf5a-3a29-4b5d-84ea-a649df6f7292", @@ -520,7 +520,7 @@ "text": "Okay read, nothing special." }, { - "userId": "a4af2f70-1f94-44a1-b0f3-2a7fe015a429", + "userId": "technology-user-1", "firstName": "Phoenix", "lastName": "Robinson", "avatarUrl": null, @@ -530,7 +530,7 @@ "text": "Good book, very nice." }, { - "userId": "5618b3ac-7e81-426d-84c8-3d047744b8da", + "userId": "technology-user-2", "firstName": "Dylan", "lastName": "Carter", "avatarUrl": "https://i.pravatar.cc/150?u=5618b3ac-7e81-426d-84c8-3d047744b8da", @@ -540,7 +540,7 @@ "text": "Not my genre, but still pretty good." }, { - "userId": "e6fcad62-ce1e-47bf-9ac6-5b3ad41fb253", + "userId": "technology-user-2", "firstName": "Kai", "lastName": "Lopez", "avatarUrl": null, @@ -550,7 +550,7 @@ "text": "The plot of 'Clean Code' has strong Education, Technical moments; the characters are well-developed and the pacing is solid." }, { - "userId": "18e19c11-d1f0-4d5d-a3e8-939a9532a827", + "userId": "technology-user-4", "firstName": "Emerson", "lastName": "Lee", "avatarUrl": "https://i.pravatar.cc/150?u=18e19c11-d1f0-4d5d-a3e8-939a9532a827", @@ -560,7 +560,7 @@ "text": "Robert C. Martin really shines in 'Clean Code' with Handbook, Agile, Software. This technology novel uses Education, Technical elements and themes from the description with clear author voice." }, { - "userId": "b8e4cd96-acf7-48f9-bb60-f16bd0ffc8be", + "userId": "technology-user-1", "firstName": "Kendall", "lastName": "Lewis", "avatarUrl": "https://i.pravatar.cc/150?u=b8e4cd96-acf7-48f9-bb60-f16bd0ffc8be", @@ -570,7 +570,7 @@ "text": "Read it in one weekend. Good enough!" }, { - "userId": "5209913e-7a64-4506-a33b-cb83dc13a632", + "userId": "mixed-reader-3", "firstName": "Logan", "lastName": "Hill", "avatarUrl": "https://i.pravatar.cc/150?u=5209913e-7a64-4506-a33b-cb83dc13a632", @@ -580,7 +580,7 @@ "text": "Dan Brown really shines in 'The Da Vinci Code' with mystery, detective, novel. This fiction novel uses Thriller, Mystery elements and themes from the description with clear author voice." }, { - "userId": "2559c5ed-bace-4a90-9584-6aa11c2507e9", + "userId": "fiction-user-1", "firstName": "Quinn", "lastName": "White", "avatarUrl": "https://i.pravatar.cc/150?u=2559c5ed-bace-4a90-9584-6aa11c2507e9", @@ -590,7 +590,7 @@ "text": "Loved it! Would recommend to friends." }, { - "userId": "7dea7f64-fc94-4104-ac1e-2a16f4f11341", + "userId": "fiction-user-3", "firstName": "Peyton", "lastName": "Phillips", "avatarUrl": "https://i.pravatar.cc/150?u=7dea7f64-fc94-4104-ac1e-2a16f4f11341", @@ -600,7 +600,7 @@ "text": "I enjoyed the fiction style, especially the parts that echo Thriller, Mystery. Not a perfect read but very worthwhile." }, { - "userId": "9db22d6a-3cdd-4f1c-998e-4d5cfc07e8fa", + "userId": "fiction-user-1", "firstName": "Rowan", "lastName": "Garcia", "avatarUrl": "https://i.pravatar.cc/150?u=9db22d6a-3cdd-4f1c-998e-4d5cfc07e8fa", @@ -610,7 +610,7 @@ "text": "I enjoyed the fiction style, especially the parts that echo Thriller, Mystery. Not a perfect read but very worthwhile." }, { - "userId": "81297492-2656-4a41-bb47-429a5d0aca46", + "userId": "fiction-user-5", "firstName": "Skyler", "lastName": "Martinez", "avatarUrl": "https://i.pravatar.cc/150?u=81297492-2656-4a41-bb47-429a5d0aca46", @@ -620,7 +620,7 @@ "text": "Cool book, maybe a bit long, but still worth it." }, { - "userId": "fce99b71-86a5-4d4a-8969-5e621cac275e", + "userId": "fiction-user-1", "firstName": "Cameron", "lastName": "Anderson", "avatarUrl": "https://i.pravatar.cc/150?u=fce99b71-86a5-4d4a-8969-5e621cac275e", @@ -630,7 +630,7 @@ "text": "I read this on vacation and it was fine." }, { - "userId": "0fa0134b-a99b-473a-94ca-6a6637cd9038", + "userId": "fiction-user-4", "firstName": "Jordan", "lastName": "Mitchell", "avatarUrl": "https://i.pravatar.cc/150?u=0fa0134b-a99b-473a-94ca-6a6637cd9038", @@ -640,7 +640,7 @@ "text": "Cool book, maybe a bit long, but still worth it." }, { - "userId": "cd5f53ae-a729-465b-bc15-5bd8654224b9", + "userId": "fiction-user-4", "firstName": "Baylor", "lastName": "Baker", "avatarUrl": "https://i.pravatar.cc/150?u=cd5f53ae-a729-465b-bc15-5bd8654224b9", @@ -650,7 +650,7 @@ "text": "Stephen King really shines in 'It' with story, seven, children. This fiction novel uses Horror elements and themes from the description with clear author voice." }, { - "userId": "d4ff18cb-f956-4ae4-ba9d-b08485069407", + "userId": "fiction-user-4", "firstName": "Quinn", "lastName": "White", "avatarUrl": "https://i.pravatar.cc/150?u=d4ff18cb-f956-4ae4-ba9d-b08485069407", @@ -660,7 +660,7 @@ "text": "Good book, very nice." }, { - "userId": "808caa45-c71c-4494-9480-f752706a3094", + "userId": "fiction-user-5", "firstName": "Skyler", "lastName": "Martinez", "avatarUrl": "https://i.pravatar.cc/150?u=808caa45-c71c-4494-9480-f752706a3094", @@ -670,7 +670,7 @@ "text": "I enjoyed the fiction style, especially the parts that echo Horror. Not a perfect read but very worthwhile." }, { - "userId": "9537b5bd-0067-4387-8603-7578f5ddc370", + "userId": "mixed-reader-4", "firstName": "Brooklyn", "lastName": "Young", "avatarUrl": "https://i.pravatar.cc/150?u=9537b5bd-0067-4387-8603-7578f5ddc370", @@ -680,7 +680,7 @@ "text": "Average book, use your judgment." }, { - "userId": "f371ce99-f106-4882-acfe-25fff94fc72c", + "userId": "mixed-reader-2", "firstName": "Finley", "lastName": "Wright", "avatarUrl": "https://i.pravatar.cc/150?u=f371ce99-f106-4882-acfe-25fff94fc72c", @@ -690,7 +690,7 @@ "text": "I enjoyed the fiction style, especially the parts that echo Fantasy, Adventure. Not a perfect read but very worthwhile." }, { - "userId": "23a478fb-12cb-4eba-8377-e1e8e0e95aff", + "userId": "fiction-user-1", "firstName": "Morgan", "lastName": "Davis", "avatarUrl": null, @@ -700,7 +700,7 @@ "text": "George R.R. Martin really shines in 'A Game of Thrones' with first, book, Song. This fiction novel uses Fantasy, Adventure elements and themes from the description with clear author voice." }, { - "userId": "f7feb906-664c-4a55-8126-d465c32e55c2", + "userId": "mixed-reader-5", "firstName": "Remy", "lastName": "Roberts", "avatarUrl": "https://i.pravatar.cc/150?u=f7feb906-664c-4a55-8126-d465c32e55c2", @@ -710,7 +710,7 @@ "text": "This book has good atmosphere, and the Adventure, Fantasy influence is evident. It reminded me of the description's highlights." }, { - "userId": "a9655ba8-b78e-4640-bf1e-53baa5c3bfc6", + "userId": "fiction-user-4", "firstName": "Drew", "lastName": "King", "avatarUrl": "https://i.pravatar.cc/150?u=a9655ba8-b78e-4640-bf1e-53baa5c3bfc6", @@ -720,7 +720,7 @@ "text": "Loved it! Would recommend to friends." }, { - "userId": "ecd3431f-7b7e-45b1-9d49-98bcf7667185", + "userId": "fiction-user-4", "firstName": "Dylan", "lastName": "Carter", "avatarUrl": null, @@ -730,7 +730,7 @@ "text": "Average book, use your judgment." }, { - "userId": "8dec431d-8f71-430e-b01c-f47bcfc0495f", + "userId": "fiction-user-5", "firstName": "Jordan", "lastName": "Lee", "avatarUrl": null, @@ -740,7 +740,7 @@ "text": "I enjoyed the fiction style, especially the parts that echo Classic, Mystery. Not a perfect read but very worthwhile." }, { - "userId": "c3a60422-714c-46fa-ab30-fc7746d577c1", + "userId": "fiction-user-3", "firstName": "Skyler", "lastName": "Scott", "avatarUrl": null, @@ -750,7 +750,7 @@ "text": "Agatha Christie really shines in 'Murder on the Orient Express' with detective, novel, featuring. This fiction novel uses Mystery, Classic elements and themes from the description with clear author voice." }, { - "userId": "ac2d0807-2749-42c0-93f7-a6580c391ad2", + "userId": "fiction-user-3", "firstName": "Avery", "lastName": "Thompson", "avatarUrl": "https://i.pravatar.cc/150?u=ac2d0807-2749-42c0-93f7-a6580c391ad2", @@ -760,7 +760,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "ae4284cc-3e35-4ecc-942c-710576f7e67a", + "userId": "fiction-user-2", "firstName": "Rowan", "lastName": "Green", "avatarUrl": "https://i.pravatar.cc/150?u=ae4284cc-3e35-4ecc-942c-710576f7e67a", @@ -770,7 +770,7 @@ "text": "Good book, very nice." }, { - "userId": "088bf1da-bef9-4c54-bab9-0a15b0fe4156", + "userId": "mixed-reader-5", "firstName": "Phoenix", "lastName": "Robinson", "avatarUrl": "https://i.pravatar.cc/150?u=088bf1da-bef9-4c54-bab9-0a15b0fe4156", @@ -780,7 +780,7 @@ "text": "This book has good atmosphere, and the Self-Help, Adventure influence is evident. It reminded me of the description's highlights." }, { - "userId": "f3a01bfd-7cf8-491b-b3c1-609fb78f102f", + "userId": "fiction-user-3", "firstName": "Cameron", "lastName": "Anderson", "avatarUrl": "https://i.pravatar.cc/150?u=f3a01bfd-7cf8-491b-b3c1-609fb78f102f", @@ -790,7 +790,7 @@ "text": "Cool book, maybe a bit long, but still worth it." }, { - "userId": "56ebdfb7-1c15-4ad5-88c6-537abee26a9f", + "userId": "fiction-user-3", "firstName": "Remy", "lastName": "Roberts", "avatarUrl": null, @@ -800,7 +800,7 @@ "text": "The plot of 'The Alchemist' has strong Adventure, Self-Help moments; the characters are well-developed and the pacing is solid." }, { - "userId": "0daae60f-e5cb-4dc5-a97a-9d323724ef8f", + "userId": "fiction-user-1", "firstName": "Brooklyn", "lastName": "Young", "avatarUrl": "https://i.pravatar.cc/150?u=0daae60f-e5cb-4dc5-a97a-9d323724ef8f", @@ -810,7 +810,7 @@ "text": "Paulo Coelho really shines in 'The Alchemist' with novel, about, following. This fiction novel uses Adventure, Self-Help elements and themes from the description with clear author voice." }, { - "userId": "208f9337-6a49-4383-92b8-6f9d083d813d", + "userId": "fiction-user-4", "firstName": "Shawn", "lastName": "Adams", "avatarUrl": null, @@ -820,7 +820,7 @@ "text": "Not my genre, but still pretty good." }, { - "userId": "8aa1504a-a030-4605-982f-dd5672d5600f", + "userId": "fiction-user-5", "firstName": "Skyler", "lastName": "Scott", "avatarUrl": "https://i.pravatar.cc/150?u=8aa1504a-a030-4605-982f-dd5672d5600f", @@ -830,7 +830,7 @@ "text": "This book has good atmosphere, and the Science Fiction, Dystopian influence is evident. It reminded me of the description's highlights." }, { - "userId": "c0873581-f402-4d5b-b293-f9c87f0cf84f", + "userId": "fiction-user-5", "firstName": "Marley", "lastName": "Nelson", "avatarUrl": "https://i.pravatar.cc/150?u=c0873581-f402-4d5b-b293-f9c87f0cf84f", @@ -840,7 +840,7 @@ "text": "Ray Bradbury really shines in 'Fahrenheit 451' with future, American, society. This fiction novel uses Dystopian, Science Fiction elements and themes from the description with clear author voice." }, { - "userId": "3edd6eee-14a5-419c-b223-aadc4498332d", + "userId": "fiction-user-5", "firstName": "Dylan", "lastName": "Carter", "avatarUrl": "https://i.pravatar.cc/150?u=3edd6eee-14a5-419c-b223-aadc4498332d", @@ -850,7 +850,7 @@ "text": "Not my genre, but still pretty good." }, { - "userId": "96b1b63e-1ecc-4db1-a603-e37cbc9d3ce6", + "userId": "mixed-reader-2", "firstName": "Phoenix", "lastName": "Robinson", "avatarUrl": "https://i.pravatar.cc/150?u=96b1b63e-1ecc-4db1-a603-e37cbc9d3ce6", @@ -860,7 +860,7 @@ "text": "This book has good atmosphere, and the Thriller, Mystery influence is evident. It reminded me of the description's highlights." }, { - "userId": "d3727c44-ab08-4e88-a28c-585589045def", + "userId": "fiction-user-1", "firstName": "Reagan", "lastName": "Clark", "avatarUrl": "https://i.pravatar.cc/150?u=d3727c44-ab08-4e88-a28c-585589045def", @@ -870,7 +870,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "cf164d10-fb96-4527-aaca-e68ac0049009", + "userId": "fiction-user-1", "firstName": "Skyler", "lastName": "Martinez", "avatarUrl": "https://i.pravatar.cc/150?u=cf164d10-fb96-4527-aaca-e68ac0049009", @@ -880,7 +880,7 @@ "text": "Gillian Flynn really shines in 'Gone Girl' with thriller, about, woman. This fiction novel uses Thriller, Mystery elements and themes from the description with clear author voice." }, { - "userId": "1102bd88-c17a-4c72-93a3-a9998ed6c70f", + "userId": "mixed-reader-1", "firstName": "Skyler", "lastName": "Martinez", "avatarUrl": "https://i.pravatar.cc/150?u=1102bd88-c17a-4c72-93a3-a9998ed6c70f", @@ -890,7 +890,7 @@ "text": "Not my genre, but still pretty good." }, { - "userId": "2b03a9ff-d9e5-4a0d-969b-8f6b9bfa4d4d", + "userId": "fiction-user-4", "firstName": "Logan", "lastName": "Hill", "avatarUrl": "https://i.pravatar.cc/150?u=2b03a9ff-d9e5-4a0d-969b-8f6b9bfa4d4d", @@ -900,7 +900,7 @@ "text": "I enjoyed the fiction style, especially the parts that echo Thriller, Mystery. Not a perfect read but very worthwhile." }, { - "userId": "502e439c-0250-46cd-bdc6-fb2349336be6", + "userId": "non-fiction-user-4", "firstName": "Emerson", "lastName": "Lee", "avatarUrl": null, @@ -910,7 +910,7 @@ "text": "Not my genre, but still pretty good." }, { - "userId": "a349a8f6-525d-4283-986e-5db6bf27be62", + "userId": "non-fiction-user-4", "firstName": "Reese", "lastName": "Martin", "avatarUrl": null, @@ -920,7 +920,7 @@ "text": "Loved it! Would recommend to friends." }, { - "userId": "6e833584-7e92-4cfa-8c26-03e53d8cd98d", + "userId": "non-fiction-user-4", "firstName": "Remy", "lastName": "Roberts", "avatarUrl": "https://i.pravatar.cc/150?u=6e833584-7e92-4cfa-8c26-03e53d8cd98d", @@ -930,7 +930,7 @@ "text": "Daniel Kahneman really shines in 'Thinking, Fast and Slow' with systems, that, drive. This non-fiction novel uses Psychology, Education elements and themes from the description with clear author voice." }, { - "userId": "860b5d61-408b-4479-ba46-a23d288e137d", + "userId": "non-fiction-user-5", "firstName": "Jamie", "lastName": "Taylor", "avatarUrl": "https://i.pravatar.cc/150?u=860b5d61-408b-4479-ba46-a23d288e137d", @@ -940,7 +940,7 @@ "text": "I enjoyed the non-fiction style, especially the parts that echo Education, Psychology. Not a perfect read but very worthwhile." }, { - "userId": "93df9aba-4571-4dc2-9695-7c46e8879e89", + "userId": "non-fiction-user-1", "firstName": "Devon", "lastName": "Harris", "avatarUrl": "https://i.pravatar.cc/150?u=93df9aba-4571-4dc2-9695-7c46e8879e89", @@ -950,7 +950,7 @@ "text": "I read this on vacation and it was fine." }, { - "userId": "9c1b5460-4a91-4217-8479-7099738346b7", + "userId": "biography-user-5", "firstName": "Phoenix", "lastName": "Robinson", "avatarUrl": "https://i.pravatar.cc/150?u=9c1b5460-4a91-4217-8479-7099738346b7", @@ -960,7 +960,7 @@ "text": "This book has good atmosphere, and the Education, Technical influence is evident. It reminded me of the description's highlights." }, { - "userId": "aa2162ad-44dd-4286-9906-b67d0af44b45", + "userId": "biography-user-1", "firstName": "Casey", "lastName": "Wilson", "avatarUrl": "https://i.pravatar.cc/150?u=aa2162ad-44dd-4286-9906-b67d0af44b45", @@ -970,7 +970,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "779d9d94-b809-4b09-95bd-6dcd33f8e122", + "userId": "biography-user-5", "firstName": "Morgan", "lastName": "Davis", "avatarUrl": null, @@ -980,7 +980,7 @@ "text": "Walter Isaacson really shines in 'Steve Jobs' with biography, co-founder, Apple. This biography novel uses Education, Technical elements and themes from the description with clear author voice." }, { - "userId": "13e88aa3-02aa-4f0d-91ba-dcc76e9d8d94", + "userId": "mixed-reader-4", "firstName": "Skyler", "lastName": "Martinez", "avatarUrl": null, @@ -990,7 +990,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "7045d8e5-bf87-4c1e-8ed8-1e59c5140dc5", + "userId": "biography-user-1", "firstName": "Peyton", "lastName": "Phillips", "avatarUrl": null, @@ -1000,7 +1000,7 @@ "text": "Good book, very nice." }, { - "userId": "7f7638ba-266c-486d-ab10-6e729047ceb0", + "userId": "fiction-user-2", "firstName": "Finley", "lastName": "Wright", "avatarUrl": "https://i.pravatar.cc/150?u=7f7638ba-266c-486d-ab10-6e729047ceb0", @@ -1010,7 +1010,7 @@ "text": "Okay read, nothing special." }, { - "userId": "2edde4a4-d1a7-40de-adfb-002afe7f7f5b", + "userId": "fiction-user-2", "firstName": "Taylor", "lastName": "Brown", "avatarUrl": null, @@ -1020,7 +1020,7 @@ "text": "This book has good atmosphere, and the Fantasy, Adventure influence is evident. It reminded me of the description's highlights." }, { - "userId": "51fbf07f-f518-49fb-9971-245b54e9e061", + "userId": "fiction-user-2", "firstName": "Skyler", "lastName": "Scott", "avatarUrl": null, @@ -1030,7 +1030,7 @@ "text": "Loved it! Would recommend to friends." }, { - "userId": "db538e9f-b947-43cb-80a6-0f1f6e0b008f", + "userId": "fiction-user-2", "firstName": "Remy", "lastName": "Roberts", "avatarUrl": "https://i.pravatar.cc/150?u=db538e9f-b947-43cb-80a6-0f1f6e0b008f", @@ -1040,7 +1040,7 @@ "text": "J.R.R. Tolkien really shines in 'The Lord of the Rings' with High, fantasy, novel. This fiction novel uses Fantasy, Adventure elements and themes from the description with clear author voice." }, { - "userId": "67539259-b813-4611-9caa-2c5a6f353167", + "userId": "fiction-user-3", "firstName": "Dylan", "lastName": "Carter", "avatarUrl": null, @@ -1050,7 +1050,7 @@ "text": "This book has good atmosphere, and the Adventure, Fantasy influence is evident. It reminded me of the description's highlights." }, { - "userId": "55011d8a-e65b-486e-abe6-a7e695c0a94e", + "userId": "fiction-user-4", "firstName": "Logan", "lastName": "Hill", "avatarUrl": null, @@ -1060,7 +1060,7 @@ "text": "Aldous Huxley really shines in 'Brave New World' with dystopian, social, science. This fiction novel uses Dystopian, Science Fiction elements and themes from the description with clear author voice." }, { - "userId": "a35c9140-93e0-4663-a66e-88e1e7835ad7", + "userId": "fiction-user-1", "firstName": "Emerson", "lastName": "Lee", "avatarUrl": "https://i.pravatar.cc/150?u=a35c9140-93e0-4663-a66e-88e1e7835ad7", @@ -1070,7 +1070,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "87f58b71-cd20-495e-a579-0e158b96d290", + "userId": "mixed-reader-5", "firstName": "Hunter", "lastName": "Perez", "avatarUrl": "https://i.pravatar.cc/150?u=87f58b71-cd20-495e-a579-0e158b96d290", @@ -1080,7 +1080,7 @@ "text": "I enjoyed the fiction style, especially the parts that echo Dystopian, Science Fiction. Not a perfect read but very worthwhile." }, { - "userId": "9ebe17df-272d-4334-8af9-6d760016ea4b", + "userId": "mixed-reader-3", "firstName": "Reagan", "lastName": "Clark", "avatarUrl": "https://i.pravatar.cc/150?u=9ebe17df-272d-4334-8af9-6d760016ea4b", @@ -1090,7 +1090,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "443ab0d7-0ccf-4cdb-8280-a0c2c258ef69", + "userId": "fiction-user-4", "firstName": "Dallas", "lastName": "Rodriguez", "avatarUrl": "https://i.pravatar.cc/150?u=443ab0d7-0ccf-4cdb-8280-a0c2c258ef69", @@ -1100,7 +1100,7 @@ "text": "Alex Michaelides really shines in 'The Silent Patient' with psychological, thriller, about. This fiction novel uses Thriller, Mystery elements and themes from the description with clear author voice." }, { - "userId": "4ec0d65d-a47c-409b-8b8f-5b4ad1964a86", + "userId": "mixed-reader-3", "firstName": "Skyler", "lastName": "Scott", "avatarUrl": null, @@ -1110,7 +1110,7 @@ "text": "This book has good atmosphere, and the Thriller, Mystery influence is evident. It reminded me of the description's highlights." }, { - "userId": "b4df50c2-476c-4014-b3a6-6183f1326502", + "userId": "fiction-user-2", "firstName": "Drew", "lastName": "King", "avatarUrl": "https://i.pravatar.cc/150?u=b4df50c2-476c-4014-b3a6-6183f1326502", @@ -1120,7 +1120,7 @@ "text": "Nice story, enjoyed it." }, { - "userId": "41dbb308-dd05-440a-8629-b8296d18c370", + "userId": "mixed-reader-4", "firstName": "Dylan", "lastName": "Carter", "avatarUrl": "https://i.pravatar.cc/150?u=41dbb308-dd05-440a-8629-b8296d18c370", @@ -1130,7 +1130,7 @@ "text": "Not my genre, but still pretty good." }, { - "userId": "9d45146b-8b31-4f04-b6fd-b9ee7eaef85c", + "userId": "fiction-user-5", "firstName": "Aiden", "lastName": "Allen", "avatarUrl": "https://i.pravatar.cc/150?u=9d45146b-8b31-4f04-b6fd-b9ee7eaef85c", @@ -1140,7 +1140,7 @@ "text": "I enjoyed the fiction style, especially the parts that echo Thriller, Mystery. Not a perfect read but very worthwhile." }, { - "userId": "ec38f20d-6a14-40a0-8a09-d63e88445f40", + "userId": "non-fiction-user-5", "firstName": "Jordan", "lastName": "Mitchell", "avatarUrl": "https://i.pravatar.cc/150?u=ec38f20d-6a14-40a0-8a09-d63e88445f40", @@ -1150,7 +1150,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "6f4247ca-3fee-4482-adab-41ef40e65857", + "userId": "non-fiction-user-3", "firstName": "Cameron", "lastName": "Anderson", "avatarUrl": "https://i.pravatar.cc/150?u=6f4247ca-3fee-4482-adab-41ef40e65857", @@ -1160,7 +1160,7 @@ "text": "Lewis Carroll really shines in 'Alice's Adventures in Wonderland' with Alice's, Adventures, Wonderland. This non-fiction novel uses Fantasy, Science Fiction elements and themes from the description with clear author voice." }, { - "userId": "b284d031-3bdf-431e-ba9d-0dcaab6622b2", + "userId": "non-fiction-user-2", "firstName": "Dallas", "lastName": "Rodriguez", "avatarUrl": "https://i.pravatar.cc/150?u=b284d031-3bdf-431e-ba9d-0dcaab6622b2", @@ -1170,7 +1170,7 @@ "text": "Good book, very nice." }, { - "userId": "57cb3e14-f48f-4c2c-8257-4434ab6b6155", + "userId": "non-fiction-user-1", "firstName": "Rowan", "lastName": "Green", "avatarUrl": "https://i.pravatar.cc/150?u=57cb3e14-f48f-4c2c-8257-4434ab6b6155", @@ -1180,7 +1180,7 @@ "text": "I enjoyed the non-fiction style, especially the parts that echo Science Fiction, Fantasy. Not a perfect read but very worthwhile." }, { - "userId": "7b39d9f1-d6e3-44a1-b6fa-3c7ee8020907", + "userId": "non-fiction-user-4", "firstName": "Hayden", "lastName": "Jackson", "avatarUrl": "https://i.pravatar.cc/150?u=7b39d9f1-d6e3-44a1-b6fa-3c7ee8020907", @@ -1190,7 +1190,7 @@ "text": "L. Frank Baum really shines in 'The Wonderful Wizard of Oz' with Over, century, after. This non-fiction novel uses Fantasy, Science Fiction elements and themes from the description with clear author voice." }, { - "userId": "67d95e9d-5da6-4c46-9eb3-71e9ecfacaa0", + "userId": "non-fiction-user-1", "firstName": "Jordan", "lastName": "Mitchell", "avatarUrl": "https://i.pravatar.cc/150?u=67d95e9d-5da6-4c46-9eb3-71e9ecfacaa0", @@ -1200,7 +1200,7 @@ "text": "The plot of 'The Wonderful Wizard of Oz' has strong Fantasy, Science Fiction moments; the characters are well-developed and the pacing is solid." }, { - "userId": "937fbc16-4161-4ca0-8db7-412647a8b19d", + "userId": "non-fiction-user-3", "firstName": "Emerson", "lastName": "Lee", "avatarUrl": "https://i.pravatar.cc/150?u=937fbc16-4161-4ca0-8db7-412647a8b19d", @@ -1210,7 +1210,7 @@ "text": "Good book, very nice." }, { - "userId": "7d8904c7-979a-4541-a46f-df16311c8488", + "userId": "non-fiction-user-4", "firstName": "Brooklyn", "lastName": "Young", "avatarUrl": null, @@ -1220,7 +1220,7 @@ "text": "This book has good atmosphere, and the Science Fiction, Fantasy influence is evident. It reminded me of the description's highlights." }, { - "userId": "f94a5685-beb9-40ac-9be9-2f087866e554", + "userId": "non-fiction-user-1", "firstName": "Phoenix", "lastName": "Robinson", "avatarUrl": "https://i.pravatar.cc/150?u=f94a5685-beb9-40ac-9be9-2f087866e554", @@ -1230,7 +1230,7 @@ "text": "The plot of 'Treasure Island' has strong Fantasy, Thriller moments; the characters are well-developed and the pacing is solid." }, { - "userId": "3ac8e319-4e57-4521-9f6d-1c4b10e637fb", + "userId": "non-fiction-user-4", "firstName": "Riley", "lastName": "Moore", "avatarUrl": "https://i.pravatar.cc/150?u=3ac8e319-4e57-4521-9f6d-1c4b10e637fb", @@ -1240,7 +1240,7 @@ "text": "Good book, very nice." }, { - "userId": "877f957b-f8c2-437f-89e0-9193daa1b9e5", + "userId": "mixed-reader-3", "firstName": "Emerson", "lastName": "Lee", "avatarUrl": "https://i.pravatar.cc/150?u=877f957b-f8c2-437f-89e0-9193daa1b9e5", @@ -1250,7 +1250,7 @@ "text": "Robert Louis Stevenson really shines in 'Treasure Island' with Traditionally, considered, coming-of-age. This non-fiction novel uses Fantasy, Thriller elements and themes from the description with clear author voice." }, { - "userId": "381c0ac6-3d10-443a-a862-b77ba9d9f928", + "userId": "non-fiction-user-5", "firstName": "Peyton", "lastName": "Phillips", "avatarUrl": null, @@ -1260,7 +1260,7 @@ "text": "I enjoyed the non-fiction style, especially the parts that echo Thriller, Fantasy. Not a perfect read but very worthwhile." }, { - "userId": "77853daf-ffec-45d2-9a47-3a722cfe922f", + "userId": "mixed-reader-5", "firstName": "Dallas", "lastName": "Rodriguez", "avatarUrl": "https://i.pravatar.cc/150?u=77853daf-ffec-45d2-9a47-3a722cfe922f", @@ -1270,7 +1270,7 @@ "text": "Nice story, enjoyed it." }, { - "userId": "848c45e0-218b-404e-81e8-b56bb7fcdd96", + "userId": "non-fiction-user-2", "firstName": "Hunter", "lastName": "Perez", "avatarUrl": "https://i.pravatar.cc/150?u=848c45e0-218b-404e-81e8-b56bb7fcdd96", @@ -1280,7 +1280,7 @@ "text": "I enjoyed the non-fiction style, especially the parts that echo Education, Fantasy. Not a perfect read but very worthwhile." }, { - "userId": "0a35e1f7-b6f6-448c-bc93-eb24ea90f1a9", + "userId": "non-fiction-user-3", "firstName": "Riley", "lastName": "Moore", "avatarUrl": "https://i.pravatar.cc/150?u=0a35e1f7-b6f6-448c-bc93-eb24ea90f1a9", @@ -1290,7 +1290,7 @@ "text": "Not my genre, but still pretty good." }, { - "userId": "affde22a-e821-4693-a015-4bfb960adb58", + "userId": "mixed-reader-5", "firstName": "Skyler", "lastName": "Martinez", "avatarUrl": "https://i.pravatar.cc/150?u=affde22a-e821-4693-a015-4bfb960adb58", @@ -1300,7 +1300,7 @@ "text": "William Shakespeare really shines in 'A Midsummer Night's Dream' with night, young, couples. This non-fiction novel uses Fantasy, Education elements and themes from the description with clear author voice." }, { - "userId": "058fc530-64dd-45ca-b4cb-9426b0b99a2f", + "userId": "non-fiction-user-3", "firstName": "Skyler", "lastName": "Scott", "avatarUrl": "https://i.pravatar.cc/150?u=058fc530-64dd-45ca-b4cb-9426b0b99a2f", @@ -1310,7 +1310,7 @@ "text": "The plot of 'A Midsummer Night's Dream' has strong Fantasy, Education moments; the characters are well-developed and the pacing is solid." }, { - "userId": "edf67174-bba8-4908-90bf-e1b3c30db255", + "userId": "non-fiction-user-3", "firstName": "Rowan", "lastName": "Green", "avatarUrl": null, @@ -1320,7 +1320,7 @@ "text": "Loved it! Would recommend to friends." }, { - "userId": "da48f876-631c-4916-9154-409796db6802", + "userId": "non-fiction-user-3", "firstName": "Hunter", "lastName": "Perez", "avatarUrl": null, @@ -1330,7 +1330,7 @@ "text": "The plot of 'The Prince' has strong Psychology, Fantasy moments; the characters are well-developed and the pacing is solid." }, { - "userId": "90058eb0-40b8-4206-9e8f-f67dcc9c53c5", + "userId": "non-fiction-user-5", "firstName": "Drew", "lastName": "King", "avatarUrl": null, @@ -1340,7 +1340,7 @@ "text": "Niccolò Machiavelli really shines in 'The Prince' with Prince, (Italian, Principe. This non-fiction novel uses Fantasy, Psychology elements and themes from the description with clear author voice." }, { - "userId": "656c509e-de5d-4e75-8fe3-09dbd5da5524", + "userId": "mixed-reader-2", "firstName": "Morgan", "lastName": "Davis", "avatarUrl": "https://i.pravatar.cc/150?u=656c509e-de5d-4e75-8fe3-09dbd5da5524", @@ -1350,7 +1350,7 @@ "text": "This book has good atmosphere, and the Psychology, Fantasy influence is evident. It reminded me of the description's highlights." }, { - "userId": "d517d09c-c1df-4d4e-ad94-4df802bdd18b", + "userId": "non-fiction-user-3", "firstName": "Remy", "lastName": "Roberts", "avatarUrl": "https://i.pravatar.cc/150?u=d517d09c-c1df-4d4e-ad94-4df802bdd18b", @@ -1360,7 +1360,7 @@ "text": "Nice story, enjoyed it." }, { - "userId": "d9f26ee6-726e-42e5-a4bc-15e52f3c7031", + "userId": "non-fiction-user-1", "firstName": "Dallas", "lastName": "Rodriguez", "avatarUrl": "https://i.pravatar.cc/150?u=d9f26ee6-726e-42e5-a4bc-15e52f3c7031", @@ -1370,7 +1370,7 @@ "text": "Lewis Carroll really shines in 'Through the Looking-Glass' with *Through, Looking-Glass, What. This non-fiction novel uses Fantasy elements and themes from the description with clear author voice." }, { - "userId": "4284826c-627f-4915-a5c1-8ec264bc5e31", + "userId": "mixed-reader-3", "firstName": "Parker", "lastName": "Thomas", "avatarUrl": "https://i.pravatar.cc/150?u=4284826c-627f-4915-a5c1-8ec264bc5e31", @@ -1380,7 +1380,7 @@ "text": "This book has good atmosphere, and the Fantasy influence is evident. It reminded me of the description's highlights." }, { - "userId": "49738c38-9246-4694-8ccc-7f9988f310b9", + "userId": "non-fiction-user-3", "firstName": "Reagan", "lastName": "Clark", "avatarUrl": "https://i.pravatar.cc/150?u=49738c38-9246-4694-8ccc-7f9988f310b9", @@ -1390,7 +1390,7 @@ "text": "This book has good atmosphere, and the Fantasy influence is evident. It reminded me of the description's highlights." }, { - "userId": "d32ae49f-8e2e-41a4-b0a7-783f091fab9b", + "userId": "fiction-user-1", "firstName": "Skyler", "lastName": "Scott", "avatarUrl": "https://i.pravatar.cc/150?u=d32ae49f-8e2e-41a4-b0a7-783f091fab9b", @@ -1400,7 +1400,7 @@ "text": "I enjoyed the fiction style, especially the parts that echo Fantasy. Not a perfect read but very worthwhile." }, { - "userId": "1e48d076-21de-4a12-8169-19b258b46913", + "userId": "fiction-user-5", "firstName": "Brooklyn", "lastName": "Young", "avatarUrl": "https://i.pravatar.cc/150?u=1e48d076-21de-4a12-8169-19b258b46913", @@ -1410,7 +1410,7 @@ "text": "Nice story, enjoyed it." }, { - "userId": "8a50e4ce-394c-4578-8f57-b3b173efe98e", + "userId": "fiction-user-3", "firstName": "Rowan", "lastName": "Garcia", "avatarUrl": "https://i.pravatar.cc/150?u=8a50e4ce-394c-4578-8f57-b3b173efe98e", @@ -1420,7 +1420,7 @@ "text": "Average book, use your judgment." }, { - "userId": "bdf3978f-355c-4e03-9c9f-b65725897929", + "userId": "fiction-user-4", "firstName": "Peyton", "lastName": "Phillips", "avatarUrl": null, @@ -1430,7 +1430,7 @@ "text": "The plot of 'The Marvelous Land of Oz' has strong Fantasy moments; the characters are well-developed and the pacing is solid." }, { - "userId": "6df382b7-d1c1-4a78-ae6f-3f927eb1df64", + "userId": "fiction-user-1", "firstName": "Skyler", "lastName": "Scott", "avatarUrl": "https://i.pravatar.cc/150?u=6df382b7-d1c1-4a78-ae6f-3f927eb1df64", @@ -1440,7 +1440,7 @@ "text": "L. Frank Baum really shines in 'The Marvelous Land of Oz' with creation, Jack, Pumpkin. This fiction novel uses Fantasy elements and themes from the description with clear author voice." }, { - "userId": "a888b562-4fbd-4a9a-80b3-53db0a19a9ed", + "userId": "non-fiction-user-4", "firstName": "Dylan", "lastName": "Carter", "avatarUrl": "https://i.pravatar.cc/150?u=a888b562-4fbd-4a9a-80b3-53db0a19a9ed", @@ -1450,7 +1450,7 @@ "text": "Antoine de Saint-Exupéry really shines in 'Le petit prince' with Petit, Prince*, œuvre. This non-fiction novel uses Fantasy, Adventure elements and themes from the description with clear author voice." }, { - "userId": "04fb87c0-c8d6-4fbd-917e-599818ff75f1", + "userId": "non-fiction-user-2", "firstName": "Jamie", "lastName": "Taylor", "avatarUrl": "https://i.pravatar.cc/150?u=04fb87c0-c8d6-4fbd-917e-599818ff75f1", @@ -1460,7 +1460,7 @@ "text": "Nice story, enjoyed it." }, { - "userId": "b4f1373f-52d5-483c-a652-23e8dfa8d16c", + "userId": "mixed-reader-4", "firstName": "Taylor", "lastName": "Brown", "avatarUrl": "https://i.pravatar.cc/150?u=b4f1373f-52d5-483c-a652-23e8dfa8d16c", @@ -1470,7 +1470,7 @@ "text": "I enjoyed the non-fiction style, especially the parts that echo Fantasy, Adventure. Not a perfect read but very worthwhile." }, { - "userId": "023c9d66-f149-43c6-b53d-747d00db5a37", + "userId": "non-fiction-user-2", "firstName": "Skyler", "lastName": "Martinez", "avatarUrl": "https://i.pravatar.cc/150?u=023c9d66-f149-43c6-b53d-747d00db5a37", @@ -1480,7 +1480,7 @@ "text": "Cool book, maybe a bit long, but still worth it." }, { - "userId": "4d9fb544-0cd3-490a-95b3-2cd6b6529ada", + "userId": "non-fiction-user-1", "firstName": "Casey", "lastName": "Wilson", "avatarUrl": "https://i.pravatar.cc/150?u=4d9fb544-0cd3-490a-95b3-2cd6b6529ada", @@ -1490,7 +1490,7 @@ "text": "Cool book, maybe a bit long, but still worth it." }, { - "userId": "fbaa9203-6100-4261-be54-84a2524e4fc2", + "userId": "non-fiction-user-5", "firstName": "Avery", "lastName": "Thompson", "avatarUrl": "https://i.pravatar.cc/150?u=fbaa9203-6100-4261-be54-84a2524e4fc2", @@ -1500,7 +1500,7 @@ "text": "L. Frank Baum really shines in 'The Emerald City of Oz' with From, book:Perhaps, should. This non-fiction novel uses Fantasy, Science Fiction elements and themes from the description with clear author voice." }, { - "userId": "b3e4bddb-4d82-4288-b497-d9ea961264f4", + "userId": "non-fiction-user-4", "firstName": "Hayden", "lastName": "Jackson", "avatarUrl": "https://i.pravatar.cc/150?u=b3e4bddb-4d82-4288-b497-d9ea961264f4", @@ -1510,7 +1510,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "57717cce-704c-4b26-becd-b8ffc102ff51", + "userId": "non-fiction-user-5", "firstName": "Hayden", "lastName": "Jackson", "avatarUrl": "https://i.pravatar.cc/150?u=57717cce-704c-4b26-becd-b8ffc102ff51", @@ -1520,7 +1520,7 @@ "text": "Good book, very nice." }, { - "userId": "89a7d7da-4ece-4492-8ffc-1241a9dbf9d8", + "userId": "non-fiction-user-5", "firstName": "Devon", "lastName": "Harris", "avatarUrl": null, @@ -1530,7 +1530,7 @@ "text": "The plot of 'The Emerald City of Oz' has strong Science Fiction, Fantasy moments; the characters are well-developed and the pacing is solid." }, { - "userId": "979528fd-a772-4c55-a1d8-e751a72a5e82", + "userId": "non-fiction-user-4", "firstName": "Logan", "lastName": "Hill", "avatarUrl": "https://i.pravatar.cc/150?u=979528fd-a772-4c55-a1d8-e751a72a5e82", @@ -1540,7 +1540,7 @@ "text": "The plot of 'Dorothy and the Wizard in Oz' has strong Science Fiction, Fantasy moments; the characters are well-developed and the pacing is solid." }, { - "userId": "a453e756-e2f4-4cd9-9318-de21f1ecb239", + "userId": "non-fiction-user-4", "firstName": "Marley", "lastName": "Nelson", "avatarUrl": "https://i.pravatar.cc/150?u=a453e756-e2f4-4cd9-9318-de21f1ecb239", @@ -1550,7 +1550,7 @@ "text": "L. Frank Baum really shines in 'Dorothy and the Wizard in Oz' with Dorothy, Wizard, fourth. This non-fiction novel uses Fantasy, Science Fiction elements and themes from the description with clear author voice." }, { - "userId": "4397edd4-4540-4598-85d4-5346bf5e32a8", + "userId": "mixed-reader-1", "firstName": "Rowan", "lastName": "Green", "avatarUrl": "https://i.pravatar.cc/150?u=4397edd4-4540-4598-85d4-5346bf5e32a8", @@ -1560,7 +1560,7 @@ "text": "Nice story, enjoyed it." }, { - "userId": "5b37c3a9-50bb-404a-a48c-e9c9628f1f5d", + "userId": "non-fiction-user-4", "firstName": "Shawn", "lastName": "Adams", "avatarUrl": "https://i.pravatar.cc/150?u=5b37c3a9-50bb-404a-a48c-e9c9628f1f5d", @@ -1570,7 +1570,7 @@ "text": "Good book, very nice." }, { - "userId": "ffd9a9f0-0ab4-4d92-ba90-368985853ef3", + "userId": "mixed-reader-1", "firstName": "Riley", "lastName": "Moore", "avatarUrl": "https://i.pravatar.cc/150?u=ffd9a9f0-0ab4-4d92-ba90-368985853ef3", @@ -1580,7 +1580,7 @@ "text": "I read this on vacation and it was fine." }, { - "userId": "1b067984-ffb4-4139-beaf-1deda4203023", + "userId": "non-fiction-user-5", "firstName": "Blake", "lastName": "Walker", "avatarUrl": "https://i.pravatar.cc/150?u=1b067984-ffb4-4139-beaf-1deda4203023", @@ -1590,7 +1590,7 @@ "text": "This book has good atmosphere, and the Fantasy influence is evident. It reminded me of the description's highlights." }, { - "userId": "db746400-1854-43d1-90fb-e76536039312", + "userId": "non-fiction-user-1", "firstName": "Jamie", "lastName": "Taylor", "avatarUrl": "https://i.pravatar.cc/150?u=db746400-1854-43d1-90fb-e76536039312", @@ -1600,7 +1600,7 @@ "text": "This book has good atmosphere, and the Fantasy influence is evident. It reminded me of the description's highlights." }, { - "userId": "3d2779ed-b580-46be-a4c5-815ab1703ba7", + "userId": "non-fiction-user-3", "firstName": "Aiden", "lastName": "Allen", "avatarUrl": "https://i.pravatar.cc/150?u=3d2779ed-b580-46be-a4c5-815ab1703ba7", @@ -1610,7 +1610,7 @@ "text": "Lewis Carroll really shines in 'Alice's Adventures in Wonderland / Through the Looking Glass' with very, real, little. This non-fiction novel uses Fantasy elements and themes from the description with clear author voice." }, { - "userId": "544a768d-0d58-4336-995b-c8e40e8f2122", + "userId": "non-fiction-user-1", "firstName": "Riley", "lastName": "Moore", "avatarUrl": "https://i.pravatar.cc/150?u=544a768d-0d58-4336-995b-c8e40e8f2122", @@ -1620,7 +1620,7 @@ "text": "Average book, use your judgment." }, { - "userId": "040526cb-c323-426f-9813-3f06df035807", + "userId": "non-fiction-user-5", "firstName": "Aiden", "lastName": "Allen", "avatarUrl": "https://i.pravatar.cc/150?u=040526cb-c323-426f-9813-3f06df035807", @@ -1630,7 +1630,7 @@ "text": "Nice story, enjoyed it." }, { - "userId": "4967b4d8-4baa-4456-8713-35844e635944", + "userId": "non-fiction-user-1", "firstName": "Rowan", "lastName": "Garcia", "avatarUrl": "https://i.pravatar.cc/150?u=4967b4d8-4baa-4456-8713-35844e635944", @@ -1640,7 +1640,7 @@ "text": "Good book, very nice." }, { - "userId": "266d820e-dd44-4d39-ac25-48e63dcc394f", + "userId": "non-fiction-user-2", "firstName": "Cameron", "lastName": "Anderson", "avatarUrl": "https://i.pravatar.cc/150?u=266d820e-dd44-4d39-ac25-48e63dcc394f", @@ -1650,7 +1650,7 @@ "text": "Read it in one weekend. Good enough!" }, { - "userId": "d7a55fbc-05b4-4c3d-b079-b4de05e8cb0b", + "userId": "non-fiction-user-5", "firstName": "Kai", "lastName": "Lopez", "avatarUrl": "https://i.pravatar.cc/150?u=d7a55fbc-05b4-4c3d-b079-b4de05e8cb0b", @@ -1660,7 +1660,7 @@ "text": "H. G. Wells really shines in 'The Time Machine' with Time, Traveller, dreamer. This non-fiction novel uses Dystopian, Science Fiction elements and themes from the description with clear author voice." }, { - "userId": "244069a9-c462-4566-a1dd-f1383d0edd78", + "userId": "mixed-reader-1", "firstName": "Jordan", "lastName": "Mitchell", "avatarUrl": null, @@ -1670,7 +1670,7 @@ "text": "This book has good atmosphere, and the Science Fiction, Dystopian influence is evident. It reminded me of the description's highlights." }, { - "userId": "f890b5b6-9fc3-489f-9522-55ed92924cf0", + "userId": "non-fiction-user-3", "firstName": "Jordan", "lastName": "Lee", "avatarUrl": null, @@ -1680,7 +1680,7 @@ "text": "Not my genre, but still pretty good." }, { - "userId": "b03e6951-396f-49b9-bfdc-e911f87f8a62", + "userId": "mixed-reader-2", "firstName": "Kendall", "lastName": "Lewis", "avatarUrl": "https://i.pravatar.cc/150?u=b03e6951-396f-49b9-bfdc-e911f87f8a62", @@ -1690,7 +1690,7 @@ "text": "I read this on vacation and it was fine." }, { - "userId": "5c54f24b-f7a1-4b38-a0ba-f5d5334b4791", + "userId": "non-fiction-user-3", "firstName": "Morgan", "lastName": "Davis", "avatarUrl": null, @@ -1700,7 +1700,7 @@ "text": "The plot of 'Dracula' has strong Fantasy, Thriller moments; the characters are well-developed and the pacing is solid." }, { - "userId": "3b41c092-b874-49ad-9bf6-cdadcea10d03", + "userId": "non-fiction-user-1", "firstName": "Riley", "lastName": "Moore", "avatarUrl": null, @@ -1710,7 +1710,7 @@ "text": "Bram Stoker really shines in 'Dracula' with história, casal, seus. This non-fiction novel uses Fantasy, Horror elements and themes from the description with clear author voice." }, { - "userId": "558bf42c-54f0-4d6e-94c0-587c9818a34d", + "userId": "non-fiction-user-3", "firstName": "Dakota", "lastName": "Turner", "avatarUrl": "https://i.pravatar.cc/150?u=558bf42c-54f0-4d6e-94c0-587c9818a34d", @@ -1720,7 +1720,7 @@ "text": "This book has good atmosphere, and the Fantasy, Thriller influence is evident. It reminded me of the description's highlights." }, { - "userId": "eb745e2b-6bd7-4ad4-a575-3a56ee8eb2fc", + "userId": "non-fiction-user-5", "firstName": "Baylor", "lastName": "Baker", "avatarUrl": null, @@ -1730,7 +1730,7 @@ "text": "Nice story, enjoyed it." }, { - "userId": "b19c4af6-2dd4-4d6c-bd42-b89b27fe5a27", + "userId": "mixed-reader-5", "firstName": "Shawn", "lastName": "Adams", "avatarUrl": "https://i.pravatar.cc/150?u=b19c4af6-2dd4-4d6c-bd42-b89b27fe5a27", @@ -1740,7 +1740,7 @@ "text": "The plot of 'The Invisible Man' has strong Science Fiction moments; the characters are well-developed and the pacing is solid." }, { - "userId": "20dc5de9-a7a9-4242-889d-d918a2e95712", + "userId": "non-fiction-user-1", "firstName": "Dylan", "lastName": "Carter", "avatarUrl": "https://i.pravatar.cc/150?u=20dc5de9-a7a9-4242-889d-d918a2e95712", @@ -1750,7 +1750,7 @@ "text": "Loved it! Would recommend to friends." }, { - "userId": "a6c83988-1929-4e1f-8cd5-b70edafd2b78", + "userId": "mixed-reader-5", "firstName": "Devon", "lastName": "Hall", "avatarUrl": null, @@ -1760,7 +1760,7 @@ "text": "H. G. Wells really shines in 'The Invisible Man' with This, book, story. This non-fiction novel uses Science Fiction elements and themes from the description with clear author voice." }, { - "userId": "dfa4fd66-7832-4de1-a028-d45cd83ac645", + "userId": "non-fiction-user-2", "firstName": "Skyler", "lastName": "Scott", "avatarUrl": "https://i.pravatar.cc/150?u=dfa4fd66-7832-4de1-a028-d45cd83ac645", @@ -1770,7 +1770,7 @@ "text": "Okay read, nothing special." }, { - "userId": "df1e42ab-5882-456b-b1e5-6e673af51afd", + "userId": "non-fiction-user-4", "firstName": "Hunter", "lastName": "Perez", "avatarUrl": "https://i.pravatar.cc/150?u=df1e42ab-5882-456b-b1e5-6e673af51afd", @@ -1780,7 +1780,7 @@ "text": "The plot of 'The Invisible Man' has strong Science Fiction moments; the characters are well-developed and the pacing is solid." }, { - "userId": "53651081-0e7d-41dd-aac9-aa0abeebac99", + "userId": "mixed-reader-2", "firstName": "Drew", "lastName": "King", "avatarUrl": "https://i.pravatar.cc/150?u=53651081-0e7d-41dd-aac9-aa0abeebac99", @@ -1790,7 +1790,7 @@ "text": "The plot of 'Le Tour du Monde en Quatre-Vingts Jours' has strong Fantasy, Science Fiction moments; the characters are well-developed and the pacing is solid." }, { - "userId": "4fedfc8f-9f29-495b-9702-67382ac22a05", + "userId": "non-fiction-user-3", "firstName": "Quinn", "lastName": "White", "avatarUrl": null, @@ -1800,7 +1800,7 @@ "text": "Jules Verne really shines in 'Le Tour du Monde en Quatre-Vingts Jours' with Phileas, Fogg, very. This non-fiction novel uses Fantasy, Science Fiction elements and themes from the description with clear author voice." }, { - "userId": "77fc88a5-e471-4286-a9c7-7cfbaeaee6bb", + "userId": "non-fiction-user-3", "firstName": "Baylor", "lastName": "Baker", "avatarUrl": null, @@ -1810,7 +1810,7 @@ "text": "I enjoyed the non-fiction style, especially the parts that echo Thriller, Fantasy. Not a perfect read but very worthwhile." }, { - "userId": "32af0ef5-9c09-4673-b05f-8fd7bb07f414", + "userId": "non-fiction-user-1", "firstName": "Hayden", "lastName": "Jackson", "avatarUrl": null, @@ -1820,7 +1820,7 @@ "text": "Cool book, maybe a bit long, but still worth it." }, { - "userId": "6452e9a2-eb93-42f4-b602-d7d442dc2b04", + "userId": "non-fiction-user-2", "firstName": "Casey", "lastName": "Wilson", "avatarUrl": "https://i.pravatar.cc/150?u=6452e9a2-eb93-42f4-b602-d7d442dc2b04", @@ -1830,7 +1830,7 @@ "text": "Read it in one weekend. Good enough!" }, { - "userId": "7a1d7d1f-a305-4b9d-a08c-7f768e0957e0", + "userId": "non-fiction-user-3", "firstName": "Reagan", "lastName": "Clark", "avatarUrl": "https://i.pravatar.cc/150?u=7a1d7d1f-a305-4b9d-a08c-7f768e0957e0", @@ -1840,7 +1840,7 @@ "text": "This book has good atmosphere, and the Fantasy, Science Fiction influence is evident. It reminded me of the description's highlights." }, { - "userId": "8c3303bc-247c-4417-892e-abd407affc09", + "userId": "non-fiction-user-2", "firstName": "Kendall", "lastName": "Lewis", "avatarUrl": "https://i.pravatar.cc/150?u=8c3303bc-247c-4417-892e-abd407affc09", @@ -1850,7 +1850,7 @@ "text": "William Morris really shines in 'News from nowhere, or, An epoch of rest, being some chapters from a utopian romance' with Written, 1890, close. This non-fiction novel uses Fantasy, Science Fiction elements and themes from the description with clear author voice." }, { - "userId": "61715258-1622-40cb-9d89-7e0171fd213e", + "userId": "non-fiction-user-2", "firstName": "Dallas", "lastName": "Rodriguez", "avatarUrl": null, @@ -1860,7 +1860,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "176e796d-9898-446c-accd-433eb702c431", + "userId": "mixed-reader-3", "firstName": "Logan", "lastName": "Hill", "avatarUrl": "https://i.pravatar.cc/150?u=176e796d-9898-446c-accd-433eb702c431", @@ -1870,7 +1870,7 @@ "text": "Good book, very nice." }, { - "userId": "72fe799b-454e-427d-b4b7-1aca88637c3a", + "userId": "fiction-user-4", "firstName": "Quinn", "lastName": "White", "avatarUrl": "https://i.pravatar.cc/150?u=72fe799b-454e-427d-b4b7-1aca88637c3a", @@ -1880,7 +1880,7 @@ "text": "This book has good atmosphere, and the Romance, Psychology influence is evident. It reminded me of the description's highlights." }, { - "userId": "edc068fe-b03d-46cf-90e1-7eafcf69dcfe", + "userId": "fiction-user-2", "firstName": "Blake", "lastName": "Walker", "avatarUrl": "https://i.pravatar.cc/150?u=edc068fe-b03d-46cf-90e1-7eafcf69dcfe", @@ -1890,7 +1890,7 @@ "text": "Nice story, enjoyed it." }, { - "userId": "97a001fc-fa2d-4775-ba75-de280e08b234", + "userId": "fiction-user-1", "firstName": "Blake", "lastName": "Walker", "avatarUrl": null, @@ -1900,7 +1900,7 @@ "text": "I enjoyed the fiction style, especially the parts that echo Psychology, Horror. Not a perfect read but very worthwhile." }, { - "userId": "b427dee6-dcc7-4061-81d4-e3e774d986ee", + "userId": "fiction-user-1", "firstName": "Brooklyn", "lastName": "Young", "avatarUrl": null, @@ -1910,7 +1910,7 @@ "text": "Thomas Hardy really shines in 'The Mayor of Casterbridge' with

Like, many, href=\"https://standardebooks.org/ebooks/thomas-hardy\">Hardy’s. This fiction novel uses Horror, Romance elements and themes from the description with clear author voice." }, { - "userId": "6aa97d44-776b-46e7-abe0-6451d04a7f45", + "userId": "non-fiction-user-2", "firstName": "Kai", "lastName": "Lopez", "avatarUrl": "https://i.pravatar.cc/150?u=6aa97d44-776b-46e7-abe0-6451d04a7f45", @@ -1920,7 +1920,7 @@ "text": "I enjoyed the non-fiction style, especially the parts that echo Fantasy, Horror. Not a perfect read but very worthwhile." }, { - "userId": "1c3e98bd-1a60-46b9-9c0d-448dd6f52c2f", + "userId": "mixed-reader-3", "firstName": "Devon", "lastName": "Harris", "avatarUrl": "https://i.pravatar.cc/150?u=1c3e98bd-1a60-46b9-9c0d-448dd6f52c2f", @@ -1930,7 +1930,7 @@ "text": "Loved it! Would recommend to friends." }, { - "userId": "5a313e07-6b96-47c9-b141-81c74a295afb", + "userId": "mixed-reader-1", "firstName": "Rowan", "lastName": "Garcia", "avatarUrl": "https://i.pravatar.cc/150?u=5a313e07-6b96-47c9-b141-81c74a295afb", @@ -1940,7 +1940,7 @@ "text": "Henry James really shines in 'The Turn of the Screw' with governess, enigmatic, children. This non-fiction novel uses Fantasy, Horror elements and themes from the description with clear author voice." }, { - "userId": "99074c5d-d912-44b6-a75b-7da7f6438981", + "userId": "non-fiction-user-2", "firstName": "Jamie", "lastName": "Taylor", "avatarUrl": "https://i.pravatar.cc/150?u=99074c5d-d912-44b6-a75b-7da7f6438981", @@ -1950,7 +1950,7 @@ "text": "This book has good atmosphere, and the Horror, Fantasy influence is evident. It reminded me of the description's highlights." }, { - "userId": "55549c5b-28a9-4a77-966d-686e18c35746", + "userId": "non-fiction-user-4", "firstName": "Logan", "lastName": "Hill", "avatarUrl": "https://i.pravatar.cc/150?u=55549c5b-28a9-4a77-966d-686e18c35746", @@ -1960,7 +1960,7 @@ "text": "The plot of 'Carrie' has strong Horror, Thriller moments; the characters are well-developed and the pacing is solid." }, { - "userId": "07a90399-0010-4bcd-bd46-2101bc750715", + "userId": "non-fiction-user-4", "firstName": "Skyler", "lastName": "Martinez", "avatarUrl": "https://i.pravatar.cc/150?u=07a90399-0010-4bcd-bd46-2101bc750715", @@ -1970,7 +1970,7 @@ "text": "Stephen King really shines in 'Carrie' with story, misfit, high-school. This non-fiction novel uses Horror, Thriller elements and themes from the description with clear author voice." }, { - "userId": "ff01be00-6d24-4499-9bd3-8ce272eac542", + "userId": "mixed-reader-1", "firstName": "Hunter", "lastName": "Perez", "avatarUrl": "https://i.pravatar.cc/150?u=ff01be00-6d24-4499-9bd3-8ce272eac542", @@ -1980,7 +1980,7 @@ "text": "Good book, very nice." }, { - "userId": "d6690c70-1d1b-4fcd-8bd0-9e12dedc4766", + "userId": "non-fiction-user-1", "firstName": "Brooklyn", "lastName": "Young", "avatarUrl": "https://i.pravatar.cc/150?u=d6690c70-1d1b-4fcd-8bd0-9e12dedc4766", @@ -1990,7 +1990,7 @@ "text": "This book has good atmosphere, and the Horror, Thriller influence is evident. It reminded me of the description's highlights." }, { - "userId": "ed06bcc4-e27f-4995-bd71-681d14a8f84a", + "userId": "fiction-user-2", "firstName": "Reagan", "lastName": "Clark", "avatarUrl": "https://i.pravatar.cc/150?u=ed06bcc4-e27f-4995-bd71-681d14a8f84a", @@ -2000,7 +2000,7 @@ "text": "The plot of 'Wuthering Heights' has strong Romance moments; the characters are well-developed and the pacing is solid." }, { - "userId": "0a87471d-e119-4137-bb42-69ca9f5c4a52", + "userId": "fiction-user-4", "firstName": "Kai", "lastName": "Lopez", "avatarUrl": null, @@ -2010,7 +2010,7 @@ "text": "Average book, use your judgment." }, { - "userId": "4673523f-a2e6-46cb-9fd5-5a40c410f519", + "userId": "mixed-reader-1", "firstName": "Devon", "lastName": "Hall", "avatarUrl": "https://i.pravatar.cc/150?u=4673523f-a2e6-46cb-9fd5-5a40c410f519", @@ -2020,7 +2020,7 @@ "text": "Emily Brontë really shines in 'Wuthering Heights' with Wuthering, Heights, 1847. This fiction novel uses Romance elements and themes from the description with clear author voice." }, { - "userId": "9992decb-dee0-4811-94a0-0115be8dbce6", + "userId": "fiction-user-1", "firstName": "Baylor", "lastName": "Baker", "avatarUrl": "https://i.pravatar.cc/150?u=9992decb-dee0-4811-94a0-0115be8dbce6", @@ -2030,7 +2030,7 @@ "text": "Not my genre, but still pretty good." }, { - "userId": "65e1b7b7-3d9e-4517-92f2-8c8a439bb83b", + "userId": "non-fiction-user-2", "firstName": "Rowan", "lastName": "Green", "avatarUrl": "https://i.pravatar.cc/150?u=65e1b7b7-3d9e-4517-92f2-8c8a439bb83b", @@ -2040,7 +2040,7 @@ "text": "The plot of 'Emma' has strong Romance moments; the characters are well-developed and the pacing is solid." }, { - "userId": "4d015de2-e915-4fdb-942e-aa088f7c690b", + "userId": "mixed-reader-3", "firstName": "Phoenix", "lastName": "Robinson", "avatarUrl": null, @@ -2050,7 +2050,7 @@ "text": "Jane Austen really shines in 'Emma' with Emma, Jane, Austen. This non-fiction novel uses Romance elements and themes from the description with clear author voice." }, { - "userId": "6e7553f9-ddd3-42c3-b964-0cedadad9b4b", + "userId": "non-fiction-user-5", "firstName": "Dakota", "lastName": "Turner", "avatarUrl": "https://i.pravatar.cc/150?u=6e7553f9-ddd3-42c3-b964-0cedadad9b4b", @@ -2060,7 +2060,7 @@ "text": "Okay read, nothing special." }, { - "userId": "6d3c357a-f59d-4212-a2c9-d71f80caff27", + "userId": "mixed-reader-2", "firstName": "Kendall", "lastName": "Lewis", "avatarUrl": null, @@ -2070,7 +2070,7 @@ "text": "Good book, very nice." }, { - "userId": "7da6c8bc-6059-4e09-a534-eb0a2b90acaa", + "userId": "non-fiction-user-2", "firstName": "Devon", "lastName": "Harris", "avatarUrl": "https://i.pravatar.cc/150?u=7da6c8bc-6059-4e09-a534-eb0a2b90acaa", @@ -2080,7 +2080,7 @@ "text": "Okay read, nothing special." }, { - "userId": "441d7ef0-d72e-4d10-b7e1-021a8fdb4185", + "userId": "non-fiction-user-4", "firstName": "Casey", "lastName": "Wilson", "avatarUrl": "https://i.pravatar.cc/150?u=441d7ef0-d72e-4d10-b7e1-021a8fdb4185", @@ -2090,7 +2090,7 @@ "text": "This book has good atmosphere, and the Romance, Classic influence is evident. It reminded me of the description's highlights." }, { - "userId": "a4c12dc5-2051-4821-8d4e-1b8fe0ffbe1c", + "userId": "non-fiction-user-5", "firstName": "Baylor", "lastName": "Baker", "avatarUrl": "https://i.pravatar.cc/150?u=a4c12dc5-2051-4821-8d4e-1b8fe0ffbe1c", @@ -2100,7 +2100,7 @@ "text": "Jane Austen really shines in 'Sense and Sensibility' with When, Dashwood, dies. This non-fiction novel uses Romance, Classic elements and themes from the description with clear author voice." }, { - "userId": "b6010c1e-aef6-4d93-9cb6-4efc631772a1", + "userId": "mixed-reader-1", "firstName": "Brooklyn", "lastName": "Young", "avatarUrl": "https://i.pravatar.cc/150?u=b6010c1e-aef6-4d93-9cb6-4efc631772a1", @@ -2110,7 +2110,7 @@ "text": "Average book, use your judgment." }, { - "userId": "84180932-99a3-4dbb-96a6-96252f165b60", + "userId": "mixed-reader-4", "firstName": "Devon", "lastName": "Hall", "avatarUrl": "https://i.pravatar.cc/150?u=84180932-99a3-4dbb-96a6-96252f165b60", @@ -2120,7 +2120,7 @@ "text": "I read this on vacation and it was fine." }, { - "userId": "8ec3f542-126c-4725-a6ff-2ceaa2ab3f6f", + "userId": "non-fiction-user-4", "firstName": "Taylor", "lastName": "Brown", "avatarUrl": null, @@ -2130,7 +2130,7 @@ "text": "Okay read, nothing special." }, { - "userId": "73d3e44a-7474-4d4e-904b-f4093c7bbbc5", + "userId": "non-fiction-user-2", "firstName": "Taylor", "lastName": "Brown", "avatarUrl": "https://i.pravatar.cc/150?u=73d3e44a-7474-4d4e-904b-f4093c7bbbc5", @@ -2140,7 +2140,7 @@ "text": "I enjoyed the non-fiction style, especially the parts that echo Romance, Classic. Not a perfect read but very worthwhile." }, { - "userId": "bd02976b-976e-45bc-b84e-6de0f48da489", + "userId": "mixed-reader-2", "firstName": "Dylan", "lastName": "Carter", "avatarUrl": "https://i.pravatar.cc/150?u=bd02976b-976e-45bc-b84e-6de0f48da489", @@ -2150,7 +2150,7 @@ "text": "This book has good atmosphere, and the Romance, Classic influence is evident. It reminded me of the description's highlights." }, { - "userId": "ed8c1abb-af71-41a6-aa71-20369193478e", + "userId": "non-fiction-user-1", "firstName": "Dallas", "lastName": "Rodriguez", "avatarUrl": "https://i.pravatar.cc/150?u=ed8c1abb-af71-41a6-aa71-20369193478e", @@ -2160,7 +2160,7 @@ "text": "Louisa May Alcott really shines in 'Little Women' with Louisa, Alcotts, classic. This non-fiction novel uses Romance, Classic elements and themes from the description with clear author voice." }, { - "userId": "9a0b4d79-c3c1-430f-b2e3-53fc87df3d9a", + "userId": "biography-user-1", "firstName": "Riley", "lastName": "Moore", "avatarUrl": "https://i.pravatar.cc/150?u=9a0b4d79-c3c1-430f-b2e3-53fc87df3d9a", @@ -2170,7 +2170,7 @@ "text": "Not my genre, but still pretty good." }, { - "userId": "dd13f4c4-5dc6-4435-bae5-d45dd6cd2d7d", + "userId": "biography-user-3", "firstName": "Emerson", "lastName": "Lee", "avatarUrl": null, @@ -2180,7 +2180,7 @@ "text": "I enjoyed the biography style, especially the parts that echo Romance. Not a perfect read but very worthwhile." }, { - "userId": "1ea0b9b2-7b5f-4dd5-b090-d700d8a70cdd", + "userId": "biography-user-5", "firstName": "Kendall", "lastName": "Lewis", "avatarUrl": "https://i.pravatar.cc/150?u=1ea0b9b2-7b5f-4dd5-b090-d700d8a70cdd", @@ -2190,7 +2190,7 @@ "text": "Лев Толстой really shines in 'Анна Каренина' with Described, William, Faulkner. This biography novel uses Romance elements and themes from the description with clear author voice." }, { - "userId": "374c1541-5c17-466d-8b5c-942a9adfd164", + "userId": "biography-user-3", "firstName": "Logan", "lastName": "Hill", "avatarUrl": "https://i.pravatar.cc/150?u=374c1541-5c17-466d-8b5c-942a9adfd164", @@ -2200,7 +2200,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "0beb5ef0-2f68-4375-b5b4-dd1e2aee1b32", + "userId": "biography-user-2", "firstName": "Devon", "lastName": "Hall", "avatarUrl": "https://i.pravatar.cc/150?u=0beb5ef0-2f68-4375-b5b4-dd1e2aee1b32", @@ -2210,7 +2210,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "9e86125d-3e23-41e5-82e8-b07972f7d296", + "userId": "non-fiction-user-4", "firstName": "Skyler", "lastName": "Martinez", "avatarUrl": "https://i.pravatar.cc/150?u=9e86125d-3e23-41e5-82e8-b07972f7d296", @@ -2220,7 +2220,7 @@ "text": "I enjoyed the non-fiction style, especially the parts that echo Romance. Not a perfect read but very worthwhile." }, { - "userId": "1f06a526-1d67-45d9-8f83-e80e4c44ba17", + "userId": "non-fiction-user-3", "firstName": "Hunter", "lastName": "Perez", "avatarUrl": null, @@ -2230,7 +2230,7 @@ "text": "Good book, very nice." }, { - "userId": "8fac95a1-c238-4c23-b30e-98ed8bf0312a", + "userId": "non-fiction-user-1", "firstName": "Aiden", "lastName": "Allen", "avatarUrl": "https://i.pravatar.cc/150?u=8fac95a1-c238-4c23-b30e-98ed8bf0312a", @@ -2240,7 +2240,7 @@ "text": "Read it in one weekend. Good enough!" }, { - "userId": "048f556e-7d46-4e54-8b4c-07c669e7a156", + "userId": "non-fiction-user-5", "firstName": "Marley", "lastName": "Nelson", "avatarUrl": "https://i.pravatar.cc/150?u=048f556e-7d46-4e54-8b4c-07c669e7a156", @@ -2250,7 +2250,7 @@ "text": "Charlotte Brontë really shines in 'Jane Eyre' with novel, somewhere, north. This non-fiction novel uses Romance elements and themes from the description with clear author voice." }, { - "userId": "95793ff5-0a9d-48b1-9e49-d752e0cfdfc2", + "userId": "non-fiction-user-3", "firstName": "Avery", "lastName": "Thompson", "avatarUrl": "https://i.pravatar.cc/150?u=95793ff5-0a9d-48b1-9e49-d752e0cfdfc2", @@ -2260,7 +2260,7 @@ "text": "Jane Austen really shines in 'Northanger Abbey' with Northanger, Abbey, both. This non-fiction novel uses Romance elements and themes from the description with clear author voice." }, { - "userId": "638282ae-9f78-4f88-a72e-b93109b6eef8", + "userId": "mixed-reader-1", "firstName": "Shawn", "lastName": "Adams", "avatarUrl": null, @@ -2270,7 +2270,7 @@ "text": "Average book, use your judgment." }, { - "userId": "3a9e0ea5-4e65-491f-97a9-9f88c7f44baf", + "userId": "non-fiction-user-3", "firstName": "Devon", "lastName": "Harris", "avatarUrl": "https://i.pravatar.cc/150?u=3a9e0ea5-4e65-491f-97a9-9f88c7f44baf", @@ -2280,7 +2280,7 @@ "text": "This book has good atmosphere, and the Romance influence is evident. It reminded me of the description's highlights." }, { - "userId": "894888d4-146c-4bb8-b142-353a97dd1e69", + "userId": "non-fiction-user-5", "firstName": "Emerson", "lastName": "Lee", "avatarUrl": "https://i.pravatar.cc/150?u=894888d4-146c-4bb8-b142-353a97dd1e69", @@ -2290,7 +2290,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "115c3f7a-a2a3-4e90-b170-1a4ae6be63c2", + "userId": "fiction-user-2", "firstName": "Skyler", "lastName": "Scott", "avatarUrl": "https://i.pravatar.cc/150?u=115c3f7a-a2a3-4e90-b170-1a4ae6be63c2", @@ -2300,7 +2300,7 @@ "text": "Okay read, nothing special." }, { - "userId": "049bfaec-f3c7-4631-9329-0e342cb4b07f", + "userId": "fiction-user-3", "firstName": "Riley", "lastName": "Moore", "avatarUrl": null, @@ -2310,7 +2310,7 @@ "text": "This book has good atmosphere, and the Romance influence is evident. It reminded me of the description's highlights." }, { - "userId": "91e12c6b-6747-422f-8a4b-5d1148b5f7d8", + "userId": "mixed-reader-5", "firstName": "Rowan", "lastName": "Green", "avatarUrl": "https://i.pravatar.cc/150?u=91e12c6b-6747-422f-8a4b-5d1148b5f7d8", @@ -2320,7 +2320,7 @@ "text": "This book has good atmosphere, and the Romance influence is evident. It reminded me of the description's highlights." }, { - "userId": "4f20e719-f42d-4ccb-94b8-666b5129b836", + "userId": "fiction-user-3", "firstName": "Parker", "lastName": "Thomas", "avatarUrl": "https://i.pravatar.cc/150?u=4f20e719-f42d-4ccb-94b8-666b5129b836", @@ -2330,7 +2330,7 @@ "text": "Edith Wharton really shines in 'Ethan Frome' with *Edith, Wharton, wrote. This fiction novel uses Romance elements and themes from the description with clear author voice." }, { - "userId": "90f9fbe8-b36f-4d7f-adbd-09fd098d8159", + "userId": "fiction-user-2", "firstName": "Skyler", "lastName": "Scott", "avatarUrl": "https://i.pravatar.cc/150?u=90f9fbe8-b36f-4d7f-adbd-09fd098d8159", @@ -2340,7 +2340,7 @@ "text": "Nice story, enjoyed it." }, { - "userId": "602477ee-5c7b-4d65-a933-05f6acf191e7", + "userId": "non-fiction-user-4", "firstName": "Riley", "lastName": "Moore", "avatarUrl": null, @@ -2350,7 +2350,7 @@ "text": "Alexandre Dumas really shines in 'Le Comte de Monte Cristo' with Thrown, prison, crime. This non-fiction novel uses Romance, Classic elements and themes from the description with clear author voice." }, { - "userId": "82463d46-2c47-45f1-94aa-39ee42e52efa", + "userId": "non-fiction-user-5", "firstName": "Parker", "lastName": "Thomas", "avatarUrl": "https://i.pravatar.cc/150?u=82463d46-2c47-45f1-94aa-39ee42e52efa", @@ -2360,7 +2360,7 @@ "text": "Loved it! Would recommend to friends." }, { - "userId": "d24343b8-103d-429c-8695-c446c9da9637", + "userId": "mixed-reader-4", "firstName": "Baylor", "lastName": "Baker", "avatarUrl": "https://i.pravatar.cc/150?u=d24343b8-103d-429c-8695-c446c9da9637", @@ -2370,7 +2370,7 @@ "text": "Okay read, nothing special." }, { - "userId": "e5a7e3f1-b5f7-4599-9d49-2bedf4c6356a", + "userId": "mixed-reader-4", "firstName": "Quinn", "lastName": "White", "avatarUrl": "https://i.pravatar.cc/150?u=e5a7e3f1-b5f7-4599-9d49-2bedf4c6356a", @@ -2380,7 +2380,7 @@ "text": "The plot of 'Le Comte de Monte Cristo' has strong Classic, Romance moments; the characters are well-developed and the pacing is solid." }, { - "userId": "893615a1-8b25-42d9-a2ba-92bbb926b3e1", + "userId": "non-fiction-user-4", "firstName": "Kendall", "lastName": "Lewis", "avatarUrl": "https://i.pravatar.cc/150?u=893615a1-8b25-42d9-a2ba-92bbb926b3e1", @@ -2390,7 +2390,7 @@ "text": "Zane Grey really shines in 'Riders of the Purple Sage' with Riders, Purple, Sage. This non-fiction novel uses Romance elements and themes from the description with clear author voice." }, { - "userId": "b4c7d617-37c5-4101-bfd0-a090b0a830d2", + "userId": "non-fiction-user-4", "firstName": "Skyler", "lastName": "Scott", "avatarUrl": null, @@ -2400,7 +2400,7 @@ "text": "The plot of 'Riders of the Purple Sage' has strong Romance moments; the characters are well-developed and the pacing is solid." }, { - "userId": "3b532221-7395-47fa-8ad1-e42cecb3efe8", + "userId": "non-fiction-user-5", "firstName": "Finley", "lastName": "Wright", "avatarUrl": "https://i.pravatar.cc/150?u=3b532221-7395-47fa-8ad1-e42cecb3efe8", @@ -2410,7 +2410,7 @@ "text": "Cool book, maybe a bit long, but still worth it." }, { - "userId": "78f2d3b1-2d2b-439d-a6f2-e70a6474dc33", + "userId": "non-fiction-user-3", "firstName": "Hayden", "lastName": "Jackson", "avatarUrl": "https://i.pravatar.cc/150?u=78f2d3b1-2d2b-439d-a6f2-e70a6474dc33", @@ -2420,7 +2420,7 @@ "text": "Read it in one weekend. Good enough!" }, { - "userId": "95679f9b-4a2c-44bc-9e4e-b5525053d840", + "userId": "mixed-reader-3", "firstName": "Brooklyn", "lastName": "Young", "avatarUrl": "https://i.pravatar.cc/150?u=95679f9b-4a2c-44bc-9e4e-b5525053d840", @@ -2430,7 +2430,7 @@ "text": "The plot of 'A Room with a View' has strong Romance moments; the characters are well-developed and the pacing is solid." }, { - "userId": "5638628f-2a36-49b4-965c-8cadac6f9bdc", + "userId": "fiction-user-5", "firstName": "Quinn", "lastName": "White", "avatarUrl": "https://i.pravatar.cc/150?u=5638628f-2a36-49b4-965c-8cadac6f9bdc", @@ -2440,7 +2440,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "4632d438-a775-4536-abc1-8bfcc31ffbeb", + "userId": "fiction-user-5", "firstName": "Reese", "lastName": "Martin", "avatarUrl": "https://i.pravatar.cc/150?u=4632d438-a775-4536-abc1-8bfcc31ffbeb", @@ -2450,7 +2450,7 @@ "text": "Nice story, enjoyed it." }, { - "userId": "529e9f3a-4c78-48c1-9117-211e262668ea", + "userId": "fiction-user-4", "firstName": "Shawn", "lastName": "Adams", "avatarUrl": "https://i.pravatar.cc/150?u=529e9f3a-4c78-48c1-9117-211e262668ea", @@ -2460,7 +2460,7 @@ "text": "E. M. Forster really shines in 'A Room with a View' with Lucy, rigid, middle-class. This fiction novel uses Romance elements and themes from the description with clear author voice." }, { - "userId": "a620b456-4aa9-42ac-bd7e-d3ea6e284303", + "userId": "fiction-user-5", "firstName": "Drew", "lastName": "King", "avatarUrl": "https://i.pravatar.cc/150?u=a620b456-4aa9-42ac-bd7e-d3ea6e284303", @@ -2470,7 +2470,7 @@ "text": "I enjoyed the fiction style, especially the parts that echo Romance. Not a perfect read but very worthwhile." }, { - "userId": "0330a18e-1695-4f72-a0dd-6a07ff7fea19", + "userId": "mixed-reader-5", "firstName": "Drew", "lastName": "King", "avatarUrl": "https://i.pravatar.cc/150?u=0330a18e-1695-4f72-a0dd-6a07ff7fea19", @@ -2480,7 +2480,7 @@ "text": "Harriet Beecher Stowe really shines in 'Uncle Tom's Cabin' with This, unforgettable, novel. This non-fiction novel uses Romance elements and themes from the description with clear author voice." }, { - "userId": "9bbf50c6-dd0c-4ebc-9204-34928cbfaf3b", + "userId": "non-fiction-user-1", "firstName": "Jordan", "lastName": "Mitchell", "avatarUrl": null, @@ -2490,7 +2490,7 @@ "text": "Nice story, enjoyed it." }, { - "userId": "1f982af8-cbb2-4e4f-9542-d2730a88f3ac", + "userId": "mixed-reader-1", "firstName": "Brooklyn", "lastName": "Young", "avatarUrl": "https://i.pravatar.cc/150?u=1f982af8-cbb2-4e4f-9542-d2730a88f3ac", @@ -2500,7 +2500,7 @@ "text": "Loved it! Would recommend to friends." }, { - "userId": "09974338-6408-4be0-8686-2a88e426e513", + "userId": "non-fiction-user-2", "firstName": "Peyton", "lastName": "Phillips", "avatarUrl": "https://i.pravatar.cc/150?u=09974338-6408-4be0-8686-2a88e426e513", @@ -2510,7 +2510,7 @@ "text": "Read it in one weekend. Good enough!" }, { - "userId": "94cae71c-fcf8-4200-a9f0-885fbcb1bd92", + "userId": "non-fiction-user-2", "firstName": "Drew", "lastName": "King", "avatarUrl": "https://i.pravatar.cc/150?u=94cae71c-fcf8-4200-a9f0-885fbcb1bd92", @@ -2520,7 +2520,7 @@ "text": "I enjoyed the non-fiction style, especially the parts that echo Romance. Not a perfect read but very worthwhile." }, { - "userId": "33f7a5e0-582b-47f3-8779-09d71a581fea", + "userId": "fiction-user-5", "firstName": "Emerson", "lastName": "Lee", "avatarUrl": "https://i.pravatar.cc/150?u=33f7a5e0-582b-47f3-8779-09d71a581fea", @@ -2530,7 +2530,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "d20e9efc-1918-45bc-9b28-a9648e5bde65", + "userId": "fiction-user-4", "firstName": "Peyton", "lastName": "Phillips", "avatarUrl": "https://i.pravatar.cc/150?u=d20e9efc-1918-45bc-9b28-a9648e5bde65", @@ -2540,7 +2540,7 @@ "text": "D. H. Lawrence really shines in 'Women in Love' with Dark, filled, with. This fiction novel uses Fantasy, Romance elements and themes from the description with clear author voice." }, { - "userId": "dc14ce34-ad5c-4609-9ba4-9e11e5375007", + "userId": "fiction-user-5", "firstName": "Baylor", "lastName": "Baker", "avatarUrl": "https://i.pravatar.cc/150?u=dc14ce34-ad5c-4609-9ba4-9e11e5375007", @@ -2550,7 +2550,7 @@ "text": "Okay read, nothing special." }, { - "userId": "ad63290c-4c60-4c78-abf3-26649d957f18", + "userId": "mixed-reader-3", "firstName": "Hunter", "lastName": "Perez", "avatarUrl": null, @@ -2560,7 +2560,7 @@ "text": "The plot of 'Women in Love' has strong Romance, Fantasy moments; the characters are well-developed and the pacing is solid." }, { - "userId": "fe2c84a8-bc1b-455f-a199-58572213f4d7", + "userId": "non-fiction-user-4", "firstName": "Hayden", "lastName": "Jackson", "avatarUrl": "https://i.pravatar.cc/150?u=fe2c84a8-bc1b-455f-a199-58572213f4d7", @@ -2570,7 +2570,7 @@ "text": "Joseph Conrad really shines in 'Heart of Darkness' with Heart, Darkness, (1899). This non-fiction novel uses Romance, Mystery elements and themes from the description with clear author voice." }, { - "userId": "6d3110dc-bc83-4c15-b9b5-3bc2c5030220", + "userId": "non-fiction-user-1", "firstName": "Cameron", "lastName": "Anderson", "avatarUrl": null, @@ -2580,7 +2580,7 @@ "text": "Read it in one weekend. Good enough!" }, { - "userId": "ea50b366-9131-4074-8719-84857de596b6", + "userId": "non-fiction-user-5", "firstName": "Hayden", "lastName": "Jackson", "avatarUrl": "https://i.pravatar.cc/150?u=ea50b366-9131-4074-8719-84857de596b6", @@ -2590,7 +2590,7 @@ "text": "This book has good atmosphere, and the Mystery, Romance influence is evident. It reminded me of the description's highlights." }, { - "userId": "e31e23b0-b0bc-4ce7-a540-94ab64e49dad", + "userId": "non-fiction-user-2", "firstName": "Peyton", "lastName": "Phillips", "avatarUrl": "https://i.pravatar.cc/150?u=e31e23b0-b0bc-4ce7-a540-94ab64e49dad", @@ -2600,7 +2600,7 @@ "text": "This book has good atmosphere, and the Mystery, Romance influence is evident. It reminded me of the description's highlights." }, { - "userId": "46d28898-de40-45f0-a83c-452559ee67dc", + "userId": "fiction-user-2", "firstName": "Peyton", "lastName": "Phillips", "avatarUrl": "https://i.pravatar.cc/150?u=46d28898-de40-45f0-a83c-452559ee67dc", @@ -2610,7 +2610,7 @@ "text": "E. M. Forster really shines in 'Howards End' with Howards, novel, Forster. This fiction novel uses Romance elements and themes from the description with clear author voice." }, { - "userId": "6471c574-e701-4b56-9405-51befecdaaf3", + "userId": "mixed-reader-1", "firstName": "Kendall", "lastName": "Lewis", "avatarUrl": null, @@ -2620,7 +2620,7 @@ "text": "This book has good atmosphere, and the Romance influence is evident. It reminded me of the description's highlights." }, { - "userId": "3d588710-13bf-4ff0-826f-bea5f33137cd", + "userId": "fiction-user-5", "firstName": "Avery", "lastName": "Thompson", "avatarUrl": "https://i.pravatar.cc/150?u=3d588710-13bf-4ff0-826f-bea5f33137cd", @@ -2630,7 +2630,7 @@ "text": "Average book, use your judgment." }, { - "userId": "085b051c-9694-4ebc-bae2-7ff19b47f81c", + "userId": "fiction-user-4", "firstName": "Devon", "lastName": "Hall", "avatarUrl": "https://i.pravatar.cc/150?u=085b051c-9694-4ebc-bae2-7ff19b47f81c", @@ -2640,7 +2640,7 @@ "text": "The plot of 'Howards End' has strong Romance moments; the characters are well-developed and the pacing is solid." }, { - "userId": "1705f07e-6919-450e-9a09-7a2d9bf1ea62", + "userId": "fiction-user-1", "firstName": "Jordan", "lastName": "Mitchell", "avatarUrl": "https://i.pravatar.cc/150?u=1705f07e-6919-450e-9a09-7a2d9bf1ea62", @@ -2650,7 +2650,7 @@ "text": "Henry James really shines in 'The Portrait of a Lady' with Young, American, Isabel. This fiction novel uses Romance elements and themes from the description with clear author voice." }, { - "userId": "dee7af8a-6f98-48f6-8c55-1e2714a9bda3", + "userId": "fiction-user-5", "firstName": "Remy", "lastName": "Roberts", "avatarUrl": null, @@ -2660,7 +2660,7 @@ "text": "Not my genre, but still pretty good." }, { - "userId": "7a1da435-7a4c-4db3-b9c4-42f07150c0ab", + "userId": "fiction-user-2", "firstName": "Marley", "lastName": "Nelson", "avatarUrl": null, @@ -2670,7 +2670,7 @@ "text": "I enjoyed the fiction style, especially the parts that echo Romance. Not a perfect read but very worthwhile." }, { - "userId": "5f25b317-3396-4db8-ba1c-19442817e207", + "userId": "fiction-user-1", "firstName": "Finley", "lastName": "Wright", "avatarUrl": "https://i.pravatar.cc/150?u=5f25b317-3396-4db8-ba1c-19442817e207", @@ -2680,7 +2680,7 @@ "text": "Loved it! Would recommend to friends." }, { - "userId": "6632a68e-419a-46c3-86db-607daf4cdb1d", + "userId": "fiction-user-1", "firstName": "Parker", "lastName": "Thomas", "avatarUrl": null, @@ -2690,7 +2690,7 @@ "text": "The plot of 'The Portrait of a Lady' has strong Romance moments; the characters are well-developed and the pacing is solid." }, { - "userId": "f9c38fac-084c-445c-8aa7-54be10c0f04d", + "userId": "non-fiction-user-4", "firstName": "Drew", "lastName": "King", "avatarUrl": "https://i.pravatar.cc/150?u=f9c38fac-084c-445c-8aa7-54be10c0f04d", @@ -2700,7 +2700,7 @@ "text": "Average book, use your judgment." }, { - "userId": "182ecc80-0a38-47bc-adb1-8646f61bb148", + "userId": "non-fiction-user-2", "firstName": "Dallas", "lastName": "Rodriguez", "avatarUrl": "https://i.pravatar.cc/150?u=182ecc80-0a38-47bc-adb1-8646f61bb148", @@ -2710,7 +2710,7 @@ "text": "This book has good atmosphere, and the Fantasy influence is evident. It reminded me of the description's highlights." }, { - "userId": "66a799ce-1bb1-4924-8ddc-4c9bd7f08293", + "userId": "non-fiction-user-1", "firstName": "Dakota", "lastName": "Turner", "avatarUrl": null, @@ -2720,7 +2720,7 @@ "text": "Charles Dickens really shines in 'A Christmas Carol' with retelling, story, about. This non-fiction novel uses Fantasy elements and themes from the description with clear author voice." }, { - "userId": "72cd369b-a66b-4756-bace-7c722af8f006", + "userId": "mixed-reader-2", "firstName": "Dylan", "lastName": "Carter", "avatarUrl": "https://i.pravatar.cc/150?u=72cd369b-a66b-4756-bace-7c722af8f006", @@ -2730,7 +2730,7 @@ "text": "Nice story, enjoyed it." }, { - "userId": "94fe201f-4d4c-4b8b-a81e-8cbb4c574e62", + "userId": "non-fiction-user-5", "firstName": "Reagan", "lastName": "Clark", "avatarUrl": null, @@ -2740,7 +2740,7 @@ "text": "I enjoyed the non-fiction style, especially the parts that echo Fantasy. Not a perfect read but very worthwhile." }, { - "userId": "4452917f-5054-476d-87bf-515410660cfc", + "userId": "mixed-reader-4", "firstName": "Emerson", "lastName": "Lee", "avatarUrl": "https://i.pravatar.cc/150?u=4452917f-5054-476d-87bf-515410660cfc", @@ -2750,7 +2750,7 @@ "text": "The plot of 'Adventures of Huckleberry Finn' has strong Classic, Adventure moments; the characters are well-developed and the pacing is solid." }, { - "userId": "211c57a1-0ad2-4fb4-ad10-48c82fdc925f", + "userId": "non-fiction-user-1", "firstName": "Skyler", "lastName": "Martinez", "avatarUrl": "https://i.pravatar.cc/150?u=211c57a1-0ad2-4fb4-ad10-48c82fdc925f", @@ -2760,7 +2760,7 @@ "text": "I read this on vacation and it was fine." }, { - "userId": "f9996357-fc84-436d-a4fd-875148a3a530", + "userId": "non-fiction-user-2", "firstName": "Remy", "lastName": "Roberts", "avatarUrl": "https://i.pravatar.cc/150?u=f9996357-fc84-436d-a4fd-875148a3a530", @@ -2770,7 +2770,7 @@ "text": "Nice story, enjoyed it." }, { - "userId": "81affbce-15cc-41cc-8c2b-43f3a0fac1b0", + "userId": "non-fiction-user-3", "firstName": "Aiden", "lastName": "Allen", "avatarUrl": "https://i.pravatar.cc/150?u=81affbce-15cc-41cc-8c2b-43f3a0fac1b0", @@ -2780,7 +2780,7 @@ "text": "This book has good atmosphere, and the Adventure, Classic influence is evident. It reminded me of the description's highlights." }, { - "userId": "06dc93a4-f971-4296-b115-8393930563b9", + "userId": "mixed-reader-2", "firstName": "Skyler", "lastName": "Scott", "avatarUrl": "https://i.pravatar.cc/150?u=06dc93a4-f971-4296-b115-8393930563b9", @@ -2790,7 +2790,7 @@ "text": "Mark Twain really shines in 'Adventures of Huckleberry Finn' with Adventures, Huckleberry, Finn. This non-fiction novel uses Adventure, Classic elements and themes from the description with clear author voice." }, { - "userId": "beb9dbda-aa6a-459e-b52f-c5e4d45fc57e", + "userId": "mixed-reader-1", "firstName": "Skyler", "lastName": "Martinez", "avatarUrl": "https://i.pravatar.cc/150?u=beb9dbda-aa6a-459e-b52f-c5e4d45fc57e", @@ -2800,7 +2800,7 @@ "text": "I enjoyed the non-fiction style, especially the parts that echo Adventure. Not a perfect read but very worthwhile." }, { - "userId": "1301f2eb-fc96-4528-b371-1c476f3d1c8a", + "userId": "non-fiction-user-1", "firstName": "Logan", "lastName": "Hill", "avatarUrl": "https://i.pravatar.cc/150?u=1301f2eb-fc96-4528-b371-1c476f3d1c8a", @@ -2810,7 +2810,7 @@ "text": "Daniel Defoe really shines in 'Robinson Crusoe' with During, several, adventurous. This non-fiction novel uses Adventure elements and themes from the description with clear author voice." }, { - "userId": "481a4166-48bb-4712-93c8-fd919246b503", + "userId": "non-fiction-user-3", "firstName": "Hunter", "lastName": "Perez", "avatarUrl": null, @@ -2820,7 +2820,7 @@ "text": "Cool book, maybe a bit long, but still worth it." }, { - "userId": "44e00927-4a28-4b3c-8299-bdd5ee137e4f", + "userId": "mixed-reader-3", "firstName": "Hunter", "lastName": "Perez", "avatarUrl": "https://i.pravatar.cc/150?u=44e00927-4a28-4b3c-8299-bdd5ee137e4f", @@ -2830,7 +2830,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "99f64c5e-96d7-491e-b604-18ba4250438a", + "userId": "mixed-reader-4", "firstName": "Marley", "lastName": "Nelson", "avatarUrl": "https://i.pravatar.cc/150?u=99f64c5e-96d7-491e-b604-18ba4250438a", @@ -2840,7 +2840,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "db54facd-70cb-47a8-ba6a-d53319cc619d", + "userId": "mixed-reader-1", "firstName": "Alex", "lastName": "Smith", "avatarUrl": "https://i.pravatar.cc/150?u=db54facd-70cb-47a8-ba6a-d53319cc619d", @@ -2850,7 +2850,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "f866943c-2bc2-4f88-bc33-c8b7a1a155b5", + "userId": "mixed-reader-1", "firstName": "Kai", "lastName": "Lopez", "avatarUrl": "https://i.pravatar.cc/150?u=f866943c-2bc2-4f88-bc33-c8b7a1a155b5", @@ -2860,7 +2860,7 @@ "text": "Average book, use your judgment." }, { - "userId": "d5a776f9-9f17-47c8-be3e-9493d4593ca2", + "userId": "non-fiction-user-2", "firstName": "Casey", "lastName": "Wilson", "avatarUrl": "https://i.pravatar.cc/150?u=d5a776f9-9f17-47c8-be3e-9493d4593ca2", @@ -2870,7 +2870,7 @@ "text": "The plot of 'Hamlet' has strong Classic moments; the characters are well-developed and the pacing is solid." }, { - "userId": "a1892ef5-0918-447f-afcc-748ee47562d1", + "userId": "mixed-reader-3", "firstName": "Finley", "lastName": "Wright", "avatarUrl": null, @@ -2880,7 +2880,7 @@ "text": "William Shakespeare really shines in 'Hamlet' with this, quintessential, Shakespeare. This non-fiction novel uses Classic elements and themes from the description with clear author voice." }, { - "userId": "3e3e4562-7b4c-4916-bf9b-94e8eea85661", + "userId": "non-fiction-user-5", "firstName": "Finley", "lastName": "Wright", "avatarUrl": "https://i.pravatar.cc/150?u=3e3e4562-7b4c-4916-bf9b-94e8eea85661", @@ -2890,7 +2890,7 @@ "text": "Okay read, nothing special." }, { - "userId": "096e5c52-a971-4ccf-9407-8be1378def42", + "userId": "mixed-reader-1", "firstName": "Alex", "lastName": "Smith", "avatarUrl": "https://i.pravatar.cc/150?u=096e5c52-a971-4ccf-9407-8be1378def42", @@ -2900,7 +2900,7 @@ "text": "Nice story, enjoyed it." }, { - "userId": "cf5cf270-2e1c-4ff0-94c9-d661ead44054", + "userId": "non-fiction-user-1", "firstName": "Dylan", "lastName": "Carter", "avatarUrl": "https://i.pravatar.cc/150?u=cf5cf270-2e1c-4ff0-94c9-d661ead44054", @@ -2910,7 +2910,7 @@ "text": "William Shakespeare really shines in 'Macbeth' with play, concerns, trusted. This non-fiction novel uses elements and themes from the description with clear author voice." }, { - "userId": "6c22a6ca-0e0a-4860-8751-3623f5840f19", + "userId": "non-fiction-user-4", "firstName": "Skyler", "lastName": "Scott", "avatarUrl": "https://i.pravatar.cc/150?u=6c22a6ca-0e0a-4860-8751-3623f5840f19", @@ -2920,7 +2920,7 @@ "text": "This book has good atmosphere, and the Non-Fiction influence is evident. It reminded me of the description's highlights." }, { - "userId": "dbcf3dca-a971-4e6f-bf7d-690420c1bf39", + "userId": "non-fiction-user-1", "firstName": "Cameron", "lastName": "Anderson", "avatarUrl": null, @@ -2930,7 +2930,7 @@ "text": "The plot of 'Macbeth' has strong Non-Fiction moments; the characters are well-developed and the pacing is solid." }, { - "userId": "2805009e-cd2b-444c-aca2-204e376c5b22", + "userId": "mixed-reader-1", "firstName": "Jordan", "lastName": "Lee", "avatarUrl": null, @@ -2940,7 +2940,7 @@ "text": "Average book, use your judgment." }, { - "userId": "4bcbc93d-d121-43df-a6cf-0650de737c5d", + "userId": "mixed-reader-2", "firstName": "Hunter", "lastName": "Perez", "avatarUrl": "https://i.pravatar.cc/150?u=4bcbc93d-d121-43df-a6cf-0650de737c5d", @@ -2950,7 +2950,7 @@ "text": "Good book, very nice." }, { - "userId": "308650ee-5007-4fb5-b965-bb4efbe50482", + "userId": "mixed-reader-3", "firstName": "Kendall", "lastName": "Lewis", "avatarUrl": "https://i.pravatar.cc/150?u=308650ee-5007-4fb5-b965-bb4efbe50482", @@ -2960,7 +2960,7 @@ "text": "This book has good atmosphere, and the Science Fiction, Thriller influence is evident. It reminded me of the description's highlights." }, { - "userId": "e34a4211-0e15-4c3b-8f9e-02ea9d635951", + "userId": "technology-user-2", "firstName": "Finley", "lastName": "Wright", "avatarUrl": "https://i.pravatar.cc/150?u=e34a4211-0e15-4c3b-8f9e-02ea9d635951", @@ -2970,7 +2970,7 @@ "text": "Isaac Asimov really shines in 'Les Robots' with Robot, fixup, novel. This technology novel uses Science Fiction, Thriller elements and themes from the description with clear author voice." }, { - "userId": "31ceb969-debe-48a3-9968-f5dddb022f5f", + "userId": "technology-user-3", "firstName": "Devon", "lastName": "Harris", "avatarUrl": "https://i.pravatar.cc/150?u=31ceb969-debe-48a3-9968-f5dddb022f5f", @@ -2980,7 +2980,7 @@ "text": "The plot of 'Les Robots' has strong Thriller, Science Fiction moments; the characters are well-developed and the pacing is solid." }, { - "userId": "cff57be9-9734-44bc-8980-4bf45ac54ddf", + "userId": "technology-user-2", "firstName": "Skyler", "lastName": "Martinez", "avatarUrl": "https://i.pravatar.cc/150?u=cff57be9-9734-44bc-8980-4bf45ac54ddf", @@ -2990,7 +2990,7 @@ "text": "Eoin Colfer really shines in 'Artemis Fowl' with Artemis, Fowl, first. This technology novel uses Fantasy elements and themes from the description with clear author voice." }, { - "userId": "849b7a32-9456-48d4-8bce-5f06e6fae02a", + "userId": "technology-user-3", "firstName": "Skyler", "lastName": "Scott", "avatarUrl": "https://i.pravatar.cc/150?u=849b7a32-9456-48d4-8bce-5f06e6fae02a", @@ -3000,7 +3000,7 @@ "text": "Loved it! Would recommend to friends." }, { - "userId": "5fe68926-9a2b-46b8-87f0-e885de5f0107", + "userId": "mixed-reader-3", "firstName": "Jordan", "lastName": "Mitchell", "avatarUrl": "https://i.pravatar.cc/150?u=5fe68926-9a2b-46b8-87f0-e885de5f0107", @@ -3010,7 +3010,7 @@ "text": "I enjoyed the technology style, especially the parts that echo Fantasy. Not a perfect read but very worthwhile." }, { - "userId": "d523620a-5ff4-4934-9da4-93882e894c3b", + "userId": "technology-user-3", "firstName": "Avery", "lastName": "Thompson", "avatarUrl": "https://i.pravatar.cc/150?u=d523620a-5ff4-4934-9da4-93882e894c3b", @@ -3020,7 +3020,7 @@ "text": "This book has good atmosphere, and the Fantasy influence is evident. It reminded me of the description's highlights." }, { - "userId": "4f42c413-7e79-48e8-b84c-b305faa87e37", + "userId": "technology-user-1", "firstName": "Blake", "lastName": "Walker", "avatarUrl": "https://i.pravatar.cc/150?u=4f42c413-7e79-48e8-b84c-b305faa87e37", @@ -3030,7 +3030,7 @@ "text": "Good book, very nice." }, { - "userId": "18170994-3791-41d7-bac7-183ff9d85d12", + "userId": "mixed-reader-5", "firstName": "Reese", "lastName": "Martin", "avatarUrl": "https://i.pravatar.cc/150?u=18170994-3791-41d7-bac7-183ff9d85d12", @@ -3040,7 +3040,7 @@ "text": "John R. Levine really shines in 'The Internet for Dummies' with Covers, hardware, software. This technology novel uses elements and themes from the description with clear author voice." }, { - "userId": "efd481c1-8b0e-4e2b-ad6b-793ed5984d47", + "userId": "technology-user-5", "firstName": "Rowan", "lastName": "Green", "avatarUrl": "https://i.pravatar.cc/150?u=efd481c1-8b0e-4e2b-ad6b-793ed5984d47", @@ -3050,7 +3050,7 @@ "text": "I read this on vacation and it was fine." }, { - "userId": "51118890-3071-4078-a007-e6a6f497f7e2", + "userId": "technology-user-3", "firstName": "Avery", "lastName": "Thompson", "avatarUrl": "https://i.pravatar.cc/150?u=51118890-3071-4078-a007-e6a6f497f7e2", @@ -3060,7 +3060,7 @@ "text": "This book has good atmosphere, and the Technology influence is evident. It reminded me of the description's highlights." }, { - "userId": "ff6ff9da-43ee-4ab5-a507-ca9ea0961503", + "userId": "technology-user-4", "firstName": "Dallas", "lastName": "Rodriguez", "avatarUrl": "https://i.pravatar.cc/150?u=ff6ff9da-43ee-4ab5-a507-ca9ea0961503", @@ -3070,7 +3070,7 @@ "text": "Nice story, enjoyed it." }, { - "userId": "809913a9-7275-42fd-b9a0-cbfc4adbaf9f", + "userId": "technology-user-2", "firstName": "Dallas", "lastName": "Rodriguez", "avatarUrl": null, @@ -3080,7 +3080,7 @@ "text": "Not my genre, but still pretty good." }, { - "userId": "1a3a98e0-81cd-4ab4-beb1-3155e343dea5", + "userId": "technology-user-4", "firstName": "Hunter", "lastName": "Perez", "avatarUrl": "https://i.pravatar.cc/150?u=1a3a98e0-81cd-4ab4-beb1-3155e343dea5", @@ -3090,7 +3090,7 @@ "text": "The plot of 'Stormbreaker' has strong Thriller, Mystery moments; the characters are well-developed and the pacing is solid." }, { - "userId": "7308f7f3-4cbb-4384-983d-c6fb5bf62ef4", + "userId": "technology-user-2", "firstName": "Blake", "lastName": "Walker", "avatarUrl": null, @@ -3100,7 +3100,7 @@ "text": "Good book, very nice." }, { - "userId": "caa26465-9214-460e-b56c-40b56ed98b9b", + "userId": "technology-user-4", "firstName": "Shawn", "lastName": "Adams", "avatarUrl": "https://i.pravatar.cc/150?u=caa26465-9214-460e-b56c-40b56ed98b9b", @@ -3110,7 +3110,7 @@ "text": "Anthony Horowitz really shines in 'Stormbreaker' with They, told, uncle. This technology novel uses Mystery, Thriller elements and themes from the description with clear author voice." }, { - "userId": "3fcf782f-ce82-4f68-8fa1-6c3a2b2a98d1", + "userId": "technology-user-5", "firstName": "Dylan", "lastName": "Carter", "avatarUrl": "https://i.pravatar.cc/150?u=3fcf782f-ce82-4f68-8fa1-6c3a2b2a98d1", @@ -3120,7 +3120,7 @@ "text": "I enjoyed the technology style, especially the parts that echo Thriller, Mystery. Not a perfect read but very worthwhile." }, { - "userId": "976d35ba-cb10-4719-a821-95c51518e601", + "userId": "mixed-reader-2", "firstName": "Alex", "lastName": "Smith", "avatarUrl": null, @@ -3130,7 +3130,7 @@ "text": "This book has good atmosphere, and the Technology influence is evident. It reminded me of the description's highlights." }, { - "userId": "addd2825-6bfb-4dc1-901d-26b547d4a930", + "userId": "technology-user-5", "firstName": "Peyton", "lastName": "Phillips", "avatarUrl": "https://i.pravatar.cc/150?u=addd2825-6bfb-4dc1-901d-26b547d4a930", @@ -3140,7 +3140,7 @@ "text": "Okay read, nothing special." }, { - "userId": "03a7d89a-e25d-4db4-9557-b9e82b1bc4a8", + "userId": "technology-user-4", "firstName": "Shawn", "lastName": "Adams", "avatarUrl": null, @@ -3150,7 +3150,7 @@ "text": "Good book, very nice." }, { - "userId": "ae615a71-c7b2-49c5-be59-03a90a39458d", + "userId": "technology-user-4", "firstName": "Alex", "lastName": "Smith", "avatarUrl": null, @@ -3160,7 +3160,7 @@ "text": "Scott Mueller really shines in 'Upgrading and repairing PCs' with Runaway, best-selling, hardware. This technology novel uses elements and themes from the description with clear author voice." }, { - "userId": "d901492f-59d1-461a-9ab5-17ab944aea06", + "userId": "technology-user-1", "firstName": "Drew", "lastName": "King", "avatarUrl": null, @@ -3170,7 +3170,7 @@ "text": "Stuart J. Russell really shines in 'Artificial intelligence' with comprehensive, up-to-date, introduction. This technology novel uses elements and themes from the description with clear author voice." }, { - "userId": "0db6c5e9-16a8-45ba-85e7-550e11da3798", + "userId": "technology-user-4", "firstName": "Skyler", "lastName": "Martinez", "avatarUrl": "https://i.pravatar.cc/150?u=0db6c5e9-16a8-45ba-85e7-550e11da3798", @@ -3180,7 +3180,7 @@ "text": "Cool book, maybe a bit long, but still worth it." }, { - "userId": "7db3ea89-c64b-4057-85b1-d349b8adca2b", + "userId": "technology-user-1", "firstName": "Riley", "lastName": "Moore", "avatarUrl": "https://i.pravatar.cc/150?u=7db3ea89-c64b-4057-85b1-d349b8adca2b", @@ -3190,7 +3190,7 @@ "text": "The plot of 'Artificial intelligence' has strong Technology moments; the characters are well-developed and the pacing is solid." }, { - "userId": "ea6cc920-57a6-4528-a391-77dc11ee0a62", + "userId": "technology-user-1", "firstName": "Drew", "lastName": "King", "avatarUrl": "https://i.pravatar.cc/150?u=ea6cc920-57a6-4528-a391-77dc11ee0a62", @@ -3200,7 +3200,7 @@ "text": "The plot of 'Artificial intelligence' has strong Technology moments; the characters are well-developed and the pacing is solid." }, { - "userId": "2fd22de2-2f10-4e02-9f9d-5d7be41b166f", + "userId": "mixed-reader-3", "firstName": "Hunter", "lastName": "Perez", "avatarUrl": "https://i.pravatar.cc/150?u=2fd22de2-2f10-4e02-9f9d-5d7be41b166f", @@ -3210,7 +3210,7 @@ "text": "The plot of 'Computer Concepts' has strong Technology moments; the characters are well-developed and the pacing is solid." }, { - "userId": "923cc45e-030c-497c-aebe-7d6947eaa6f1", + "userId": "technology-user-2", "firstName": "Reagan", "lastName": "Clark", "avatarUrl": "https://i.pravatar.cc/150?u=923cc45e-030c-497c-aebe-7d6947eaa6f1", @@ -3220,7 +3220,7 @@ "text": "I read this on vacation and it was fine." }, { - "userId": "347caad8-a7bb-4205-a26c-1171aeb5c454", + "userId": "technology-user-2", "firstName": "Devon", "lastName": "Harris", "avatarUrl": "https://i.pravatar.cc/150?u=347caad8-a7bb-4205-a26c-1171aeb5c454", @@ -3230,7 +3230,7 @@ "text": "Read it in one weekend. Good enough!" }, { - "userId": "3023edda-bac6-480c-aa1f-744583751b9f", + "userId": "technology-user-2", "firstName": "Remy", "lastName": "Roberts", "avatarUrl": null, @@ -3240,7 +3240,7 @@ "text": "June Jamrich Parsons really shines in 'Computer Concepts' with description, available. This technology novel uses elements and themes from the description with clear author voice." }, { - "userId": "45da94f9-1b7f-427f-8973-ccacf4a8623d", + "userId": "technology-user-2", "firstName": "Morgan", "lastName": "Davis", "avatarUrl": null, @@ -3250,7 +3250,7 @@ "text": "I read this on vacation and it was fine." }, { - "userId": "dc34996f-bcf7-4276-9d0a-c71153e6e212", + "userId": "technology-user-4", "firstName": "Dakota", "lastName": "Turner", "avatarUrl": "https://i.pravatar.cc/150?u=dc34996f-bcf7-4276-9d0a-c71153e6e212", @@ -3260,7 +3260,7 @@ "text": "This book has good atmosphere, and the Science Fiction influence is evident. It reminded me of the description's highlights." }, { - "userId": "727fb121-68d5-4e01-8721-08e3fc1273ee", + "userId": "mixed-reader-1", "firstName": "Emerson", "lastName": "Lee", "avatarUrl": null, @@ -3270,7 +3270,7 @@ "text": "This book has good atmosphere, and the Science Fiction influence is evident. It reminded me of the description's highlights." }, { - "userId": "eaed5610-eb41-4685-8c6c-1571cf1b2da6", + "userId": "mixed-reader-2", "firstName": "Rowan", "lastName": "Garcia", "avatarUrl": "https://i.pravatar.cc/150?u=eaed5610-eb41-4685-8c6c-1571cf1b2da6", @@ -3280,7 +3280,7 @@ "text": "William Gibson really shines in 'Mona Lisa Overdrive' with Mona, Lisa, Overdrive. This technology novel uses Science Fiction elements and themes from the description with clear author voice." }, { - "userId": "2f150349-bfd7-4dc0-bfa2-6030bcb0cc71", + "userId": "technology-user-4", "firstName": "Jamie", "lastName": "Taylor", "avatarUrl": null, @@ -3290,7 +3290,7 @@ "text": "Paul J. Deitel really shines in 'C++' with *Publisher's, description:*, Introduction. This technology novel uses elements and themes from the description with clear author voice." }, { - "userId": "bdaf508c-e836-44aa-a42e-7bfdd4cbb365", + "userId": "technology-user-5", "firstName": "Reagan", "lastName": "Clark", "avatarUrl": null, @@ -3300,7 +3300,7 @@ "text": "Cool book, maybe a bit long, but still worth it." }, { - "userId": "7b550357-ddd4-45c3-9308-39e54fbeafc5", + "userId": "technology-user-4", "firstName": "Logan", "lastName": "Hill", "avatarUrl": "https://i.pravatar.cc/150?u=7b550357-ddd4-45c3-9308-39e54fbeafc5", @@ -3310,7 +3310,7 @@ "text": "Okay read, nothing special." }, { - "userId": "77d7cd1c-6f16-4b6d-bd6e-8f3d2216e227", + "userId": "technology-user-1", "firstName": "Jordan", "lastName": "Lee", "avatarUrl": null, @@ -3320,7 +3320,7 @@ "text": "Nice story, enjoyed it." }, { - "userId": "6eebf64c-cb19-402d-92d5-2c01b10c5005", + "userId": "technology-user-2", "firstName": "Dakota", "lastName": "Turner", "avatarUrl": "https://i.pravatar.cc/150?u=6eebf64c-cb19-402d-92d5-2c01b10c5005", @@ -3330,7 +3330,7 @@ "text": "This book has good atmosphere, and the Technology influence is evident. It reminded me of the description's highlights." }, { - "userId": "e2dc1af7-db51-444d-a92c-ee2dfae863d5", + "userId": "biography-user-5", "firstName": "Quinn", "lastName": "White", "avatarUrl": "https://i.pravatar.cc/150?u=e2dc1af7-db51-444d-a92c-ee2dfae863d5", @@ -3340,7 +3340,7 @@ "text": "This book has good atmosphere, and the Education influence is evident. It reminded me of the description's highlights." }, { - "userId": "a6a0b256-7ad1-4238-bac0-d68ff3ff75d2", + "userId": "biography-user-3", "firstName": "Casey", "lastName": "Wilson", "avatarUrl": "https://i.pravatar.cc/150?u=a6a0b256-7ad1-4238-bac0-d68ff3ff75d2", @@ -3350,7 +3350,7 @@ "text": "Loved it! Would recommend to friends." }, { - "userId": "a3150bb0-c414-4042-a880-de1d97e334a5", + "userId": "biography-user-1", "firstName": "Shawn", "lastName": "Adams", "avatarUrl": "https://i.pravatar.cc/150?u=a3150bb0-c414-4042-a880-de1d97e334a5", @@ -3360,7 +3360,7 @@ "text": "William Shakespeare really shines in 'Julius Caesar' with Presents, original, text. This biography novel uses Education elements and themes from the description with clear author voice." }, { - "userId": "cf69e9ad-f704-4f29-a5c9-008d665d90c8", + "userId": "biography-user-5", "firstName": "Parker", "lastName": "Thomas", "avatarUrl": "https://i.pravatar.cc/150?u=cf69e9ad-f704-4f29-a5c9-008d665d90c8", @@ -3370,7 +3370,7 @@ "text": "Okay read, nothing special." }, { - "userId": "f0df07e1-702f-46ee-af0e-b1362ca8cc9e", + "userId": "mixed-reader-2", "firstName": "Peyton", "lastName": "Phillips", "avatarUrl": "https://i.pravatar.cc/150?u=f0df07e1-702f-46ee-af0e-b1362ca8cc9e", @@ -3380,7 +3380,7 @@ "text": "This book has good atmosphere, and the Education influence is evident. It reminded me of the description's highlights." }, { - "userId": "1263cb5d-79b7-4fe2-953e-5652c2493416", + "userId": "biography-user-4", "firstName": "Hunter", "lastName": "Perez", "avatarUrl": "https://i.pravatar.cc/150?u=1263cb5d-79b7-4fe2-953e-5652c2493416", @@ -3390,7 +3390,7 @@ "text": "Frederick Douglass really shines in 'Narrative of the life of Frederick Douglass' with This, book, autobiographical. This biography novel uses elements and themes from the description with clear author voice." }, { - "userId": "31ace216-0c98-423c-a85e-bc4fc7f10800", + "userId": "biography-user-1", "firstName": "Drew", "lastName": "King", "avatarUrl": "https://i.pravatar.cc/150?u=31ace216-0c98-423c-a85e-bc4fc7f10800", @@ -3400,7 +3400,7 @@ "text": "I enjoyed the biography style, especially the parts that echo Biography. Not a perfect read but very worthwhile." }, { - "userId": "14bdd3d9-fd64-452e-8ec5-c79509fbc143", + "userId": "biography-user-3", "firstName": "Dylan", "lastName": "Carter", "avatarUrl": null, @@ -3410,7 +3410,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "1a7432e0-cfae-41fe-b866-401ffb114aa9", + "userId": "biography-user-2", "firstName": "Skyler", "lastName": "Scott", "avatarUrl": "https://i.pravatar.cc/150?u=1a7432e0-cfae-41fe-b866-401ffb114aa9", @@ -3420,7 +3420,7 @@ "text": "Okay read, nothing special." }, { - "userId": "bf827f4f-ef8f-442c-8247-95af96e7d409", + "userId": "biography-user-1", "firstName": "Dallas", "lastName": "Rodriguez", "avatarUrl": "https://i.pravatar.cc/150?u=bf827f4f-ef8f-442c-8247-95af96e7d409", @@ -3430,7 +3430,7 @@ "text": "Read it in one weekend. Good enough!" }, { - "userId": "6936c579-e1ab-4227-b181-a9879be0b173", + "userId": "biography-user-1", "firstName": "Dylan", "lastName": "Carter", "avatarUrl": "https://i.pravatar.cc/150?u=6936c579-e1ab-4227-b181-a9879be0b173", @@ -3440,7 +3440,7 @@ "text": "Solomon Northup really shines in 'Twelve years a slave' with Twelve, Years, Slave. This biography novel uses elements and themes from the description with clear author voice." }, { - "userId": "4473d17d-2b1b-4ee1-b036-2a468f34c458", + "userId": "mixed-reader-4", "firstName": "Taylor", "lastName": "Brown", "avatarUrl": "https://i.pravatar.cc/150?u=4473d17d-2b1b-4ee1-b036-2a468f34c458", @@ -3450,7 +3450,7 @@ "text": "This book has good atmosphere, and the Biography influence is evident. It reminded me of the description's highlights." }, { - "userId": "b93ee634-fe4f-49d2-81ed-886c5915eca0", + "userId": "biography-user-2", "firstName": "Dylan", "lastName": "Carter", "avatarUrl": "https://i.pravatar.cc/150?u=b93ee634-fe4f-49d2-81ed-886c5915eca0", @@ -3460,7 +3460,7 @@ "text": "Cool book, maybe a bit long, but still worth it." }, { - "userId": "00906352-cb72-45dc-add5-2c564f347d64", + "userId": "biography-user-1", "firstName": "Skyler", "lastName": "Martinez", "avatarUrl": "https://i.pravatar.cc/150?u=00906352-cb72-45dc-add5-2c564f347d64", @@ -3470,7 +3470,7 @@ "text": "I read this on vacation and it was fine." }, { - "userId": "18bc3e05-9643-4ec2-8db6-d049731f118c", + "userId": "biography-user-3", "firstName": "Peyton", "lastName": "Phillips", "avatarUrl": null, @@ -3480,7 +3480,7 @@ "text": "Okay read, nothing special." }, { - "userId": "a3f3a04d-aed1-42b6-8a9b-1fed31222e3a", + "userId": "biography-user-3", "firstName": "Remy", "lastName": "Roberts", "avatarUrl": "https://i.pravatar.cc/150?u=a3f3a04d-aed1-42b6-8a9b-1fed31222e3a", @@ -3490,7 +3490,7 @@ "text": "Willa Cather really shines in 'My Ántonia' with Antonia, first, published. This biography novel uses elements and themes from the description with clear author voice." }, { - "userId": "7c132812-b0f2-4b38-8d7d-9984b3e6426a", + "userId": "biography-user-3", "firstName": "Rowan", "lastName": "Green", "avatarUrl": null, @@ -3500,7 +3500,7 @@ "text": "Average book, use your judgment." }, { - "userId": "bcfda15b-b300-415d-b29d-5ca2b9faaa84", + "userId": "mixed-reader-1", "firstName": "Hayden", "lastName": "Jackson", "avatarUrl": "https://i.pravatar.cc/150?u=bcfda15b-b300-415d-b29d-5ca2b9faaa84", @@ -3510,7 +3510,7 @@ "text": "I enjoyed the biography style, especially the parts that echo Biography. Not a perfect read but very worthwhile." }, { - "userId": "b8248fd0-15e1-42ed-92c6-897d9bdde005", + "userId": "biography-user-5", "firstName": "Avery", "lastName": "Thompson", "avatarUrl": "https://i.pravatar.cc/150?u=b8248fd0-15e1-42ed-92c6-897d9bdde005", @@ -3520,7 +3520,7 @@ "text": "The plot of 'My Ántonia' has strong Biography moments; the characters are well-developed and the pacing is solid." }, { - "userId": "d4803ff1-c77f-47d8-a7ab-7664fade4999", + "userId": "biography-user-5", "firstName": "Logan", "lastName": "Hill", "avatarUrl": "https://i.pravatar.cc/150?u=d4803ff1-c77f-47d8-a7ab-7664fade4999", @@ -3530,7 +3530,7 @@ "text": "Loved it! Would recommend to friends." }, { - "userId": "82e1b73e-cfe2-4162-bfe3-b070a69c79a9", + "userId": "biography-user-3", "firstName": "Skyler", "lastName": "Scott", "avatarUrl": null, @@ -3540,7 +3540,7 @@ "text": "Geoffrey Chaucer really shines in 'The Canterbury Tales' with collection, stories, written. This biography novel uses elements and themes from the description with clear author voice." }, { - "userId": "d4d02153-70f0-4b3f-b301-81c5969265e2", + "userId": "biography-user-5", "firstName": "Devon", "lastName": "Harris", "avatarUrl": "https://i.pravatar.cc/150?u=d4d02153-70f0-4b3f-b301-81c5969265e2", @@ -3550,7 +3550,7 @@ "text": "Good book, very nice." }, { - "userId": "0b0bc5d5-9c5b-4ccb-ae4d-cff881efc31e", + "userId": "biography-user-2", "firstName": "Peyton", "lastName": "Phillips", "avatarUrl": "https://i.pravatar.cc/150?u=0b0bc5d5-9c5b-4ccb-ae4d-cff881efc31e", @@ -3560,7 +3560,7 @@ "text": "Nice story, enjoyed it." }, { - "userId": "44b3b137-e752-4039-8d24-aa56b50cfe8c", + "userId": "biography-user-4", "firstName": "Brooklyn", "lastName": "Young", "avatarUrl": null, @@ -3570,7 +3570,7 @@ "text": "This book has good atmosphere, and the Biography influence is evident. It reminded me of the description's highlights." }, { - "userId": "b3f67ccc-af4b-482b-8c62-c2b27bf20611", + "userId": "biography-user-2", "firstName": "Casey", "lastName": "Wilson", "avatarUrl": "https://i.pravatar.cc/150?u=b3f67ccc-af4b-482b-8c62-c2b27bf20611", @@ -3580,7 +3580,7 @@ "text": "Cool book, maybe a bit long, but still worth it." }, { - "userId": "8b9770cd-10dc-4387-b374-9d19eb12f10c", + "userId": "biography-user-3", "firstName": "Devon", "lastName": "Hall", "avatarUrl": "https://i.pravatar.cc/150?u=8b9770cd-10dc-4387-b374-9d19eb12f10c", @@ -3590,7 +3590,7 @@ "text": "I enjoyed the biography style, especially the parts that echo Biography. Not a perfect read but very worthwhile." }, { - "userId": "df33a445-47ca-4064-9024-9aad825ac5c3", + "userId": "mixed-reader-3", "firstName": "Kendall", "lastName": "Lewis", "avatarUrl": "https://i.pravatar.cc/150?u=df33a445-47ca-4064-9024-9aad825ac5c3", @@ -3600,7 +3600,7 @@ "text": "William Shakespeare really shines in 'Sonnets' with feel, that, have. This biography novel uses elements and themes from the description with clear author voice." }, { - "userId": "05212d3f-2dab-44f9-9dcf-29c56d7d8415", + "userId": "biography-user-4", "firstName": "Parker", "lastName": "Thomas", "avatarUrl": "https://i.pravatar.cc/150?u=05212d3f-2dab-44f9-9dcf-29c56d7d8415", @@ -3610,7 +3610,7 @@ "text": "Loved it! Would recommend to friends." }, { - "userId": "ba640810-49ea-4736-b54c-0a5ec4297ba7", + "userId": "mixed-reader-3", "firstName": "Devon", "lastName": "Harris", "avatarUrl": "https://i.pravatar.cc/150?u=ba640810-49ea-4736-b54c-0a5ec4297ba7", @@ -3620,7 +3620,7 @@ "text": "Average book, use your judgment." }, { - "userId": "d5f3dbee-6aaa-41c1-875c-0c314c6fcb05", + "userId": "biography-user-2", "firstName": "Finley", "lastName": "Wright", "avatarUrl": "https://i.pravatar.cc/150?u=d5f3dbee-6aaa-41c1-875c-0c314c6fcb05", @@ -3630,7 +3630,7 @@ "text": "Read it in one weekend. Good enough!" }, { - "userId": "6cf5cd5b-669d-40d6-9926-9a52ef10ed07", + "userId": "biography-user-3", "firstName": "Logan", "lastName": "Hill", "avatarUrl": "https://i.pravatar.cc/150?u=6cf5cd5b-669d-40d6-9926-9a52ef10ed07", @@ -3640,7 +3640,7 @@ "text": "This book has good atmosphere, and the Biography influence is evident. It reminded me of the description's highlights." }, { - "userId": "160558da-c574-4737-8658-d5eab8d9ce23", + "userId": "biography-user-1", "firstName": "Parker", "lastName": "Thomas", "avatarUrl": null, @@ -3650,7 +3650,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "1056171a-7f55-48ac-ac24-14aeafb11932", + "userId": "biography-user-4", "firstName": "Shawn", "lastName": "Adams", "avatarUrl": "https://i.pravatar.cc/150?u=1056171a-7f55-48ac-ac24-14aeafb11932", @@ -3660,7 +3660,7 @@ "text": "Mohandas Karamchand Gandhi really shines in 'An autobiography' with Gandhi's, non-violent, struggles. This biography novel uses elements and themes from the description with clear author voice." }, { - "userId": "6800881f-3a42-4a8f-8bee-2d5b0042c17e", + "userId": "biography-user-4", "firstName": "Logan", "lastName": "Hill", "avatarUrl": "https://i.pravatar.cc/150?u=6800881f-3a42-4a8f-8bee-2d5b0042c17e", @@ -3670,7 +3670,7 @@ "text": "This book has good atmosphere, and the Biography influence is evident. It reminded me of the description's highlights." }, { - "userId": "838f936c-9440-4738-8009-7eb29dd92815", + "userId": "non-fiction-user-4", "firstName": "Dylan", "lastName": "Carter", "avatarUrl": "https://i.pravatar.cc/150?u=838f936c-9440-4738-8009-7eb29dd92815", @@ -3680,7 +3680,7 @@ "text": "Average book, use your judgment." }, { - "userId": "f51eff27-d066-40cf-9bf0-81b7b4d24434", + "userId": "mixed-reader-5", "firstName": "Dylan", "lastName": "Carter", "avatarUrl": null, @@ -3690,7 +3690,7 @@ "text": "This book has good atmosphere, and the Fantasy, Classic influence is evident. It reminded me of the description's highlights." }, { - "userId": "00871b6b-c46a-4913-a808-1b6955055a55", + "userId": "non-fiction-user-4", "firstName": "Skyler", "lastName": "Martinez", "avatarUrl": null, @@ -3700,7 +3700,7 @@ "text": "The plot of 'The Secret Garden' has strong Fantasy, Classic moments; the characters are well-developed and the pacing is solid." }, { - "userId": "9e187a67-0dc9-4105-98c9-47d221a8079a", + "userId": "mixed-reader-5", "firstName": "Devon", "lastName": "Hall", "avatarUrl": "https://i.pravatar.cc/150?u=9e187a67-0dc9-4105-98c9-47d221a8079a", @@ -3710,7 +3710,7 @@ "text": "Frances Hodgson Burnett really shines in 'The Secret Garden' with ten-year-old, orphan, comes. This non-fiction novel uses Fantasy, Classic elements and themes from the description with clear author voice." }, { - "userId": "f6368ac8-eab0-44f0-95e3-ae2e9fd16feb", + "userId": "mixed-reader-1", "firstName": "Hunter", "lastName": "Perez", "avatarUrl": "https://i.pravatar.cc/150?u=f6368ac8-eab0-44f0-95e3-ae2e9fd16feb", @@ -3720,7 +3720,7 @@ "text": "Okay read, nothing special." }, { - "userId": "72c68070-fd78-4a05-b201-fae283828dfc", + "userId": "non-fiction-user-4", "firstName": "Quinn", "lastName": "White", "avatarUrl": "https://i.pravatar.cc/150?u=72c68070-fd78-4a05-b201-fae283828dfc", @@ -3730,7 +3730,7 @@ "text": "Read it in one weekend. Good enough!" }, { - "userId": "c580a149-0b54-4333-9ca0-9764f398efc9", + "userId": "non-fiction-user-4", "firstName": "Hunter", "lastName": "Perez", "avatarUrl": null, @@ -3740,7 +3740,7 @@ "text": "Gustave Flaubert really shines in 'Madame Bovary' with Charles, Bovary, médecin. This non-fiction novel uses elements and themes from the description with clear author voice." }, { - "userId": "8d3bfad8-6101-4f3d-bff5-de1139466937", + "userId": "non-fiction-user-1", "firstName": "Jamie", "lastName": "Taylor", "avatarUrl": null, @@ -3750,7 +3750,7 @@ "text": "The plot of 'Madame Bovary' has strong Non-Fiction moments; the characters are well-developed and the pacing is solid." }, { - "userId": "59dbe493-9ed6-4695-b7b6-350f4198f53f", + "userId": "non-fiction-user-4", "firstName": "Finley", "lastName": "Wright", "avatarUrl": "https://i.pravatar.cc/150?u=59dbe493-9ed6-4695-b7b6-350f4198f53f", @@ -3760,7 +3760,7 @@ "text": "孙武 really shines in 'The Art of War' with ancient, Chinese, military. This non-fiction novel uses elements and themes from the description with clear author voice." }, { - "userId": "9899e074-fd45-4bbd-afbe-0960c8449212", + "userId": "non-fiction-user-5", "firstName": "Riley", "lastName": "Moore", "avatarUrl": "https://i.pravatar.cc/150?u=9899e074-fd45-4bbd-afbe-0960c8449212", @@ -3770,7 +3770,7 @@ "text": "I read this on vacation and it was fine." }, { - "userId": "d4799d63-660d-4197-a1d6-367ec4011ee9", + "userId": "mixed-reader-4", "firstName": "Aiden", "lastName": "Allen", "avatarUrl": "https://i.pravatar.cc/150?u=d4799d63-660d-4197-a1d6-367ec4011ee9", @@ -3780,7 +3780,7 @@ "text": "Not my genre, but still pretty good." }, { - "userId": "270e718c-c945-46d3-95c7-d65b681cc187", + "userId": "non-fiction-user-5", "firstName": "Alex", "lastName": "Smith", "avatarUrl": "https://i.pravatar.cc/150?u=270e718c-c945-46d3-95c7-d65b681cc187", @@ -3790,7 +3790,7 @@ "text": "The plot of 'The Art of War' has strong Non-Fiction moments; the characters are well-developed and the pacing is solid." }, { - "userId": "b4f29e82-2b68-4d59-a74c-3d7a7e109eef", + "userId": "non-fiction-user-2", "firstName": "Hunter", "lastName": "Perez", "avatarUrl": "https://i.pravatar.cc/150?u=b4f29e82-2b68-4d59-a74c-3d7a7e109eef", @@ -3800,7 +3800,7 @@ "text": "This book has good atmosphere, and the Non-Fiction influence is evident. It reminded me of the description's highlights." }, { - "userId": "b65cadb8-faf4-4524-a0ec-afc356962410", + "userId": "non-fiction-user-4", "firstName": "Morgan", "lastName": "Davis", "avatarUrl": null, @@ -3810,7 +3810,7 @@ "text": "Фёдор Михайлович Достоевский really shines in 'Записки изъ подполья' with nameless, hero, profoundly. This non-fiction novel uses elements and themes from the description with clear author voice." }, { - "userId": "05fb8716-faec-425c-9621-38f651eca34d", + "userId": "non-fiction-user-1", "firstName": "Cameron", "lastName": "Anderson", "avatarUrl": "https://i.pravatar.cc/150?u=05fb8716-faec-425c-9621-38f651eca34d", @@ -3820,7 +3820,7 @@ "text": "Cool book, maybe a bit long, but still worth it." }, { - "userId": "1d30c267-9a4f-4b61-a30d-03750719bba5", + "userId": "non-fiction-user-5", "firstName": "Alex", "lastName": "Smith", "avatarUrl": "https://i.pravatar.cc/150?u=1d30c267-9a4f-4b61-a30d-03750719bba5", @@ -3830,7 +3830,7 @@ "text": "Loved it! Would recommend to friends." }, { - "userId": "fb05e457-d0bc-4c4a-ab6c-52f338a3def3", + "userId": "non-fiction-user-3", "firstName": "Parker", "lastName": "Thomas", "avatarUrl": "https://i.pravatar.cc/150?u=fb05e457-d0bc-4c4a-ab6c-52f338a3def3", @@ -3840,7 +3840,7 @@ "text": "Cool book, maybe a bit long, but still worth it." }, { - "userId": "372f994b-dcda-4723-acc6-4bdf9fcdef09", + "userId": "non-fiction-user-3", "firstName": "Alex", "lastName": "Smith", "avatarUrl": null, @@ -3850,7 +3850,7 @@ "text": "I enjoyed the non-fiction style, especially the parts that echo Education. Not a perfect read but very worthwhile." }, { - "userId": "04424dd9-7a59-4832-9465-c3442de78f4b", + "userId": "non-fiction-user-5", "firstName": "Rowan", "lastName": "Garcia", "avatarUrl": "https://i.pravatar.cc/150?u=04424dd9-7a59-4832-9465-c3442de78f4b", @@ -3860,7 +3860,7 @@ "text": "Loved it! Would recommend to friends." }, { - "userId": "a61546b6-afa6-4485-b149-c42347849a13", + "userId": "non-fiction-user-2", "firstName": "Blake", "lastName": "Walker", "avatarUrl": "https://i.pravatar.cc/150?u=a61546b6-afa6-4485-b149-c42347849a13", @@ -3870,7 +3870,7 @@ "text": "Not my genre, but still pretty good." }, { - "userId": "0afee10a-9d10-4353-9f4b-1e36524d8d29", + "userId": "non-fiction-user-5", "firstName": "Rowan", "lastName": "Green", "avatarUrl": null, @@ -3880,7 +3880,7 @@ "text": "Πλάτων really shines in 'πολιτεία' with Republic, Plato's, most. This non-fiction novel uses Education elements and themes from the description with clear author voice." }, { - "userId": "d378ad7a-600d-4637-aa9b-b5372964a838", + "userId": "non-fiction-user-3", "firstName": "Jamie", "lastName": "Taylor", "avatarUrl": null, @@ -3890,7 +3890,7 @@ "text": "Good book, very nice." }, { - "userId": "e38c50b1-bce1-4f55-a19e-2f32a3a375db", + "userId": "non-fiction-user-1", "firstName": "Avery", "lastName": "Thompson", "avatarUrl": null, @@ -3900,7 +3900,7 @@ "text": "老子 really shines in 'Tao te Ching' with Within, ancient, Chinese. This non-fiction novel uses elements and themes from the description with clear author voice." }, { - "userId": "1bfc71c5-11c6-418f-9c58-bd385df919bd", + "userId": "mixed-reader-5", "firstName": "Cameron", "lastName": "Anderson", "avatarUrl": "https://i.pravatar.cc/150?u=1bfc71c5-11c6-418f-9c58-bd385df919bd", @@ -3910,7 +3910,7 @@ "text": "Nice story, enjoyed it." }, { - "userId": "3fa5b540-713f-4887-9a8e-d5cdb59f0779", + "userId": "mixed-reader-1", "firstName": "Emerson", "lastName": "Lee", "avatarUrl": "https://i.pravatar.cc/150?u=3fa5b540-713f-4887-9a8e-d5cdb59f0779", @@ -3920,7 +3920,7 @@ "text": "Read it in one weekend. Good enough!" }, { - "userId": "66501615-2ab6-4c9a-87cf-507b70675db4", + "userId": "non-fiction-user-2", "firstName": "Riley", "lastName": "Moore", "avatarUrl": null, @@ -3930,7 +3930,7 @@ "text": "The ending was surprising, but the beginning was slow." }, { - "userId": "b27395f4-219b-47cf-b0ff-48b63b58a64a", + "userId": "mixed-reader-1", "firstName": "Dakota", "lastName": "Turner", "avatarUrl": null, @@ -3940,7 +3940,7 @@ "text": "This book has good atmosphere, and the Non-Fiction influence is evident. It reminded me of the description's highlights." }, { - "userId": "1e1c3752-45c6-4013-8dd8-cc4cda947653", + "userId": "mixed-reader-1", "firstName": "Marley", "lastName": "Nelson", "avatarUrl": "https://i.pravatar.cc/150?u=1e1c3752-45c6-4013-8dd8-cc4cda947653", @@ -3950,7 +3950,7 @@ "text": "I read this on vacation and it was fine." }, { - "userId": "88e32400-ed91-4431-b9fc-0e47b6f524b9", + "userId": "non-fiction-user-3", "firstName": "Emerson", "lastName": "Lee", "avatarUrl": null, @@ -3960,7 +3960,7 @@ "text": "Friedrich Nietzsche really shines in 'Also sprach Zarathustra' with landmark, work, philosophy. This non-fiction novel uses Classic elements and themes from the description with clear author voice." }, { - "userId": "cec78f18-7d68-4282-8aa0-f3eff890ec90", + "userId": "non-fiction-user-4", "firstName": "Dylan", "lastName": "Carter", "avatarUrl": "https://i.pravatar.cc/150?u=cec78f18-7d68-4282-8aa0-f3eff890ec90", @@ -3970,7 +3970,7 @@ "text": "Loved it! Would recommend to friends." }, { - "userId": "b0571069-69b0-4804-97f5-1ed39ad5721c", + "userId": "mixed-reader-5", "firstName": "Remy", "lastName": "Roberts", "avatarUrl": null, @@ -3980,7 +3980,7 @@ "text": "Cool book, maybe a bit long, but still worth it." }, { - "userId": "623028f7-5e73-4ac3-a752-76d89c05a677", + "userId": "non-fiction-user-4", "firstName": "Morgan", "lastName": "Davis", "avatarUrl": "https://i.pravatar.cc/150?u=623028f7-5e73-4ac3-a752-76d89c05a677", @@ -3990,7 +3990,7 @@ "text": "This book has good atmosphere, and the Classic influence is evident. It reminded me of the description's highlights." }, { - "userId": "a1db471c-0e16-4228-8a15-b97093ce72ae", + "userId": "mixed-reader-4", "firstName": "Drew", "lastName": "King", "avatarUrl": "https://i.pravatar.cc/150?u=a1db471c-0e16-4228-8a15-b97093ce72ae", @@ -4000,7 +4000,7 @@ "text": "The plot of 'Romeo and Juliet' has strong Non-Fiction moments; the characters are well-developed and the pacing is solid." }, { - "userId": "4991fc15-cda2-420e-988e-fd7722232395", + "userId": "non-fiction-user-4", "firstName": "Casey", "lastName": "Wilson", "avatarUrl": "https://i.pravatar.cc/150?u=4991fc15-cda2-420e-988e-fd7722232395", @@ -4010,7 +4010,7 @@ "text": "Loved it! Would recommend to friends." }, { - "userId": "4e4b840d-d177-44e3-85a6-6e5ae76fed38", + "userId": "mixed-reader-5", "firstName": "Skyler", "lastName": "Martinez", "avatarUrl": null, @@ -4020,7 +4020,7 @@ "text": "William Shakespeare really shines in 'Romeo and Juliet' with Romeo, Juliet, tragedy. This non-fiction novel uses elements and themes from the description with clear author voice." }, { - "userId": "b8583067-9385-4d8e-84e3-35ea87c2f48e", + "userId": "non-fiction-user-3", "firstName": "Skyler", "lastName": "Scott", "avatarUrl": "https://i.pravatar.cc/150?u=b8583067-9385-4d8e-84e3-35ea87c2f48e", @@ -4030,7 +4030,7 @@ "text": "This book has good atmosphere, and the Non-Fiction influence is evident. It reminded me of the description's highlights." }, { - "userId": "3f03c8f5-2492-4634-b21c-357ce933fea7", + "userId": "non-fiction-user-5", "firstName": "Devon", "lastName": "Harris", "avatarUrl": null, @@ -4040,7 +4040,7 @@ "text": "Loved it! Would recommend to friends." }, { - "userId": "fc86e651-039d-481a-83c0-07119ce73ec8", + "userId": "biography-user-3", "firstName": "Cameron", "lastName": "Anderson", "avatarUrl": "https://i.pravatar.cc/150?u=fc86e651-039d-481a-83c0-07119ce73ec8", @@ -4050,7 +4050,7 @@ "text": "Richard Henry Dana really shines in 'Two years before the mast' with *Two, Years, before. This biography novel uses elements and themes from the description with clear author voice." }, { - "userId": "a207aea8-ec75-489b-adf1-0acdbc6b94c3", + "userId": "mixed-reader-4", "firstName": "Kai", "lastName": "Lopez", "avatarUrl": null, @@ -4060,7 +4060,7 @@ "text": "Okay read, nothing special." }, { - "userId": "5a7048bc-e9c4-4e15-999c-244c3fd06c0b", + "userId": "biography-user-3", "firstName": "Hunter", "lastName": "Perez", "avatarUrl": "https://i.pravatar.cc/150?u=5a7048bc-e9c4-4e15-999c-244c3fd06c0b", @@ -4070,7 +4070,7 @@ "text": "Average book, use your judgment." }, { - "userId": "3699061b-5a18-4d29-81d3-ec4e3cf80eb8", + "userId": "biography-user-3", "firstName": "Aiden", "lastName": "Allen", "avatarUrl": null, @@ -4080,7 +4080,7 @@ "text": "I enjoyed the biography style, especially the parts that echo Biography. Not a perfect read but very worthwhile." }, { - "userId": "6c188deb-59cc-45c1-a317-2867ff6a60fc", + "userId": "technology-user-1", "firstName": "Aiden", "lastName": "Allen", "avatarUrl": "https://i.pravatar.cc/150?u=6c188deb-59cc-45c1-a317-2867ff6a60fc", @@ -4090,7 +4090,7 @@ "text": "This book has good atmosphere, and the Education influence is evident. It reminded me of the description's highlights." }, { - "userId": "b27e3449-d254-4f07-b77b-504a82c64799", + "userId": "mixed-reader-5", "firstName": "Taylor", "lastName": "Brown", "avatarUrl": "https://i.pravatar.cc/150?u=b27e3449-d254-4f07-b77b-504a82c64799", @@ -4100,7 +4100,7 @@ "text": "Good book, very nice." }, { - "userId": "560caa4e-48ed-4daa-b23a-a8a74e748562", + "userId": "technology-user-4", "firstName": "Emerson", "lastName": "Lee", "avatarUrl": null, @@ -4110,7 +4110,7 @@ "text": "Gerard J. Tortora really shines in 'Principles of Anatomy and Physiology' with This, classic, text. This technology novel uses Education elements and themes from the description with clear author voice." }, { - "userId": "fbcea92d-6753-440d-9d87-b7ccd02a6d60", + "userId": "technology-user-2", "firstName": "Casey", "lastName": "Wilson", "avatarUrl": "https://i.pravatar.cc/150?u=fbcea92d-6753-440d-9d87-b7ccd02a6d60", @@ -4120,7 +4120,7 @@ "text": "This book has good atmosphere, and the Education influence is evident. It reminded me of the description's highlights." }, { - "userId": "59497891-c8e1-476c-a8da-8627363db35a", + "userId": "mixed-reader-3", "firstName": "Devon", "lastName": "Thomas", "avatarUrl": null, @@ -4130,7 +4130,7 @@ "text": "Too long and too slow for my preference." }, { - "userId": "283b22cc-17a7-4c45-a66a-cb5ea5df10aa", + "userId": "fiction-user-5", "firstName": "Cameron", "lastName": "Garcia", "avatarUrl": null, @@ -4140,7 +4140,7 @@ "text": "Amazing pace and character development." }, { - "userId": "2078d9f2-c975-47e0-8647-0eb072f8c345", + "userId": "fiction-user-1", "firstName": "Cameron", "lastName": "Perez", "avatarUrl": null, @@ -4150,7 +4150,7 @@ "text": "Loved the story, will read again." }, { - "userId": "e3fd1c13-ae1f-4039-88df-356bfe12a44e", + "userId": "mixed-reader-5", "firstName": "Morgan", "lastName": "White", "avatarUrl": null, @@ -4160,7 +4160,7 @@ "text": "Too long and too slow for my preference." }, { - "userId": "df0092ed-e2b7-4b7e-8831-f8e5f6e83772", + "userId": "mixed-reader-5", "firstName": "Rowan", "lastName": "Wilson", "avatarUrl": null, @@ -4170,7 +4170,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "635db28a-9ecd-42ac-b37d-6b870c523127", + "userId": "mixed-reader-1", "firstName": "Phoenix", "lastName": "Perez", "avatarUrl": null, @@ -4180,7 +4180,7 @@ "text": "Amazing pace and character development." }, { - "userId": "992d535f-c9f4-4c7e-9220-b65c3f4d937d", + "userId": "mixed-reader-3", "firstName": "Dallas", "lastName": "Taylor", "avatarUrl": null, @@ -4190,7 +4190,7 @@ "text": "Amazing pace and character development." }, { - "userId": "5aac0615-aab4-437f-86a6-4273f2d56e3e", + "userId": "fiction-user-2", "firstName": "Finley", "lastName": "Smith", "avatarUrl": null, @@ -4200,7 +4200,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "eae49e8b-0cb6-4dc6-a5fd-72c3fcedb094", + "userId": "mixed-reader-1", "firstName": "Phoenix", "lastName": "Jones", "avatarUrl": null, @@ -4210,7 +4210,7 @@ "text": "Loved the story, will read again." }, { - "userId": "ddcc12db-3289-4b1b-9a82-1581eb51f574", + "userId": "fiction-user-3", "firstName": "Quinn", "lastName": "Smith", "avatarUrl": null, @@ -4220,7 +4220,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "0bec7251-4ee3-4b9b-b66b-8a795ac39172", + "userId": "fiction-user-2", "firstName": "Drew", "lastName": "Hernandez", "avatarUrl": null, @@ -4230,7 +4230,7 @@ "text": "Loved the story, will read again." }, { - "userId": "0763ba1b-5171-4bc9-af05-f647dfe766c4", + "userId": "fiction-user-5", "firstName": "Cameron", "lastName": "Brown", "avatarUrl": null, @@ -4240,7 +4240,7 @@ "text": "Amazing pace and character development." }, { - "userId": "a4d15010-7843-451a-b7b4-1ad480c88c89", + "userId": "mixed-reader-2", "firstName": "Riley", "lastName": "Davis", "avatarUrl": null, @@ -4250,7 +4250,7 @@ "text": "Loved the story, will read again." }, { - "userId": "83e2c586-b278-475b-a8c6-7d1647dc9802", + "userId": "fiction-user-1", "firstName": "Jamie", "lastName": "Johnson", "avatarUrl": null, @@ -4260,7 +4260,7 @@ "text": "Boring and uninteresting." }, { - "userId": "2975d036-0b0f-4859-af42-c0776a84b320", + "userId": "fiction-user-1", "firstName": "Baylor", "lastName": "Garcia", "avatarUrl": null, @@ -4270,7 +4270,7 @@ "text": "Loved the story, will read again." }, { - "userId": "e581b1d2-9e8a-48a6-be72-5437916b2e5a", + "userId": "fiction-user-1", "firstName": "Kai", "lastName": "Harris", "avatarUrl": null, @@ -4280,7 +4280,7 @@ "text": "Too long and too slow for my preference." }, { - "userId": "25d20311-2ecb-4d27-8960-33036f9b22d3", + "userId": "fiction-user-3", "firstName": "Kendall", "lastName": "Johnson", "avatarUrl": null, @@ -4290,7 +4290,7 @@ "text": "This was okay overall, but not really my taste." }, { - "userId": "2e553535-6962-4006-ab5f-8b7912277dea", + "userId": "mixed-reader-3", "firstName": "Avery", "lastName": "Martinez", "avatarUrl": null, @@ -4300,7 +4300,7 @@ "text": "Boring and uninteresting." }, { - "userId": "d9ac48f0-94a6-4e7c-bfb9-990c1bbddbfa", + "userId": "mixed-reader-5", "firstName": "Devon", "lastName": "Wilson", "avatarUrl": null, @@ -4310,7 +4310,7 @@ "text": "Amazing pace and character development." }, { - "userId": "786f2718-1842-4487-997a-b5eac64130ed", + "userId": "non-fiction-user-4", "firstName": "Phoenix", "lastName": "White", "avatarUrl": null, @@ -4320,7 +4320,7 @@ "text": "This was okay overall, but not really my taste." }, { - "userId": "309d2805-0805-49ba-8b1a-fc822c1264c6", + "userId": "non-fiction-user-5", "firstName": "Parker", "lastName": "White", "avatarUrl": null, @@ -4330,7 +4330,7 @@ "text": "Too long and too slow for my preference." }, { - "userId": "b8e1c713-abcd-4c82-9985-ac135f3655b2", + "userId": "non-fiction-user-2", "firstName": "Kai", "lastName": "Smith", "avatarUrl": null, @@ -4340,7 +4340,7 @@ "text": "Too long and too slow for my preference." }, { - "userId": "554acb25-e5dd-42cd-9114-e9b9e2ab1936", + "userId": "mixed-reader-4", "firstName": "Reese", "lastName": "Clark", "avatarUrl": null, @@ -4350,7 +4350,7 @@ "text": "Amazing pace and character development." }, { - "userId": "c7e36d63-c910-4f00-bd79-6410b8a20db9", + "userId": "fiction-user-4", "firstName": "Baylor", "lastName": "Sanchez", "avatarUrl": null, @@ -4360,7 +4360,7 @@ "text": "Boring and uninteresting." }, { - "userId": "d1d6f189-6598-48fb-84da-b2178213f214", + "userId": "fiction-user-5", "firstName": "Alex", "lastName": "Martin", "avatarUrl": null, @@ -4370,7 +4370,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "b61b4d3c-8255-4d62-b318-7be687982802", + "userId": "fiction-user-2", "firstName": "Reese", "lastName": "Ramirez", "avatarUrl": null, @@ -4380,7 +4380,7 @@ "text": "This was okay overall, but not really my taste." }, { - "userId": "96d9735e-7e6d-46d5-9dfc-5aacb3efc209", + "userId": "fiction-user-5", "firstName": "Reagan", "lastName": "White", "avatarUrl": null, @@ -4390,7 +4390,7 @@ "text": "Not into it, but the style is nice." }, { - "userId": "355e9f2f-b2f7-40e3-ab9a-34eac93271dd", + "userId": "mixed-reader-3", "firstName": "Kendall", "lastName": "Wilson", "avatarUrl": null, @@ -4400,7 +4400,7 @@ "text": "This was okay overall, but not really my taste." }, { - "userId": "fc669b36-f461-491f-8d5b-c37b7b89f414", + "userId": "fiction-user-2", "firstName": "Phoenix", "lastName": "Thomas", "avatarUrl": null, @@ -4410,7 +4410,7 @@ "text": "This was okay overall, but not really my taste." }, { - "userId": "ada9853f-7c99-4cbd-b30e-469082f195d3", + "userId": "mixed-reader-1", "firstName": "Emerson", "lastName": "Smith", "avatarUrl": null, @@ -4420,7 +4420,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "4ea50d8b-1d35-493e-b797-be26377ecde1", + "userId": "fiction-user-5", "firstName": "Finley", "lastName": "Harris", "avatarUrl": null, @@ -4430,7 +4430,7 @@ "text": "Loved the story, will read again." }, { - "userId": "eb23b8b7-f586-4439-935f-df4bc7bbeec4", + "userId": "fiction-user-3", "firstName": "Baylor", "lastName": "Harris", "avatarUrl": null, @@ -4440,7 +4440,7 @@ "text": "Too long and too slow for my preference." }, { - "userId": "3334f2ed-e77e-4d47-b8ae-4ab39356045f", + "userId": "non-fiction-user-1", "firstName": "Blake", "lastName": "Williams", "avatarUrl": null, @@ -4450,7 +4450,7 @@ "text": "Amazing pace and character development." }, { - "userId": "844964a8-cdce-478f-acab-48bc809b0dd1", + "userId": "biography-user-4", "firstName": "Marley", "lastName": "Martin", "avatarUrl": null, @@ -4460,7 +4460,7 @@ "text": "Not into it, but the style is nice." }, { - "userId": "94888377-9579-4db8-bbd1-54447bd64eea", + "userId": "mixed-reader-2", "firstName": "Rowan", "lastName": "Davis", "avatarUrl": null, @@ -4470,7 +4470,7 @@ "text": "Amazing pace and character development." }, { - "userId": "054d839a-b73e-4fb9-b302-f522a75e8927", + "userId": "fiction-user-3", "firstName": "Casey", "lastName": "Ramirez", "avatarUrl": null, @@ -4480,7 +4480,7 @@ "text": "Amazing pace and character development." }, { - "userId": "4265ead0-d58b-41b0-8154-5977f172f075", + "userId": "fiction-user-5", "firstName": "Cameron", "lastName": "Johnson", "avatarUrl": null, @@ -4490,7 +4490,7 @@ "text": "Loved the story, will read again." }, { - "userId": "cb9d56af-5a84-4037-9d62-de747400e8f3", + "userId": "fiction-user-2", "firstName": "Jordan", "lastName": "Martinez", "avatarUrl": null, @@ -4500,7 +4500,7 @@ "text": "Not into it, but the style is nice." }, { - "userId": "16e855d7-f82b-4c0c-9213-1b7ac0790315", + "userId": "mixed-reader-1", "firstName": "Hayden", "lastName": "Taylor", "avatarUrl": null, @@ -4510,7 +4510,7 @@ "text": "Amazing pace and character development." }, { - "userId": "9f6b6410-3d1d-4711-b6c9-00d3a12d2fd2", + "userId": "fiction-user-5", "firstName": "Kai", "lastName": "Garcia", "avatarUrl": null, @@ -4520,7 +4520,7 @@ "text": "Not into it, but the style is nice." }, { - "userId": "9139d14c-ca30-41b2-a7c7-5f42c0509c64", + "userId": "non-fiction-user-4", "firstName": "Hunter", "lastName": "Moore", "avatarUrl": null, @@ -4530,7 +4530,7 @@ "text": "Loved the story, will read again." }, { - "userId": "20dc64d7-462c-4a5d-b325-dd040f10da32", + "userId": "mixed-reader-4", "firstName": "Reagan", "lastName": "Harris", "avatarUrl": null, @@ -4540,7 +4540,7 @@ "text": "Amazing pace and character development." }, { - "userId": "ae1efe15-1e76-4d12-9800-49310e953999", + "userId": "non-fiction-user-4", "firstName": "Skyler", "lastName": "Martin", "avatarUrl": null, @@ -4550,7 +4550,7 @@ "text": "Amazing pace and character development." }, { - "userId": "b22b4770-eee8-4cda-98ef-515169d0153f", + "userId": "non-fiction-user-2", "firstName": "Riley", "lastName": "Smith", "avatarUrl": null, @@ -4560,7 +4560,7 @@ "text": "Too long and too slow for my preference." }, { - "userId": "b113f63c-cd37-4b70-a071-7aed794c2197", + "userId": "non-fiction-user-4", "firstName": "Phoenix", "lastName": "Hernandez", "avatarUrl": null, @@ -4570,7 +4570,7 @@ "text": "Loved the story, will read again." }, { - "userId": "8c54ae69-56be-44d7-802b-36e3c84861bd", + "userId": "non-fiction-user-5", "firstName": "Baylor", "lastName": "Smith", "avatarUrl": null, @@ -4580,7 +4580,7 @@ "text": "Not into it, but the style is nice." }, { - "userId": "2571f48c-8672-4fb4-b7dd-387ee3b3b48b", + "userId": "mixed-reader-3", "firstName": "Jamie", "lastName": "Hernandez", "avatarUrl": null, @@ -4590,7 +4590,7 @@ "text": "Amazing pace and character development." }, { - "userId": "816dcbe8-2339-4e81-a81c-174dd1aba706", + "userId": "non-fiction-user-5", "firstName": "Rowan", "lastName": "Brown", "avatarUrl": null, @@ -4600,7 +4600,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "55fa3bfd-3295-435d-8762-ab995e29c844", + "userId": "non-fiction-user-4", "firstName": "Logan", "lastName": "Taylor", "avatarUrl": null, @@ -4610,7 +4610,7 @@ "text": "This was okay overall, but not really my taste." }, { - "userId": "909fdfe2-002b-40b9-92e0-80067bc9aaa6", + "userId": "non-fiction-user-1", "firstName": "Devon", "lastName": "Ramirez", "avatarUrl": null, @@ -4620,7 +4620,7 @@ "text": "Amazing pace and character development." }, { - "userId": "a2c96ebe-0720-4ffa-86ff-b40e34f206b6", + "userId": "non-fiction-user-2", "firstName": "Dylan", "lastName": "Miller", "avatarUrl": null, @@ -4630,7 +4630,7 @@ "text": "Boring and uninteresting." }, { - "userId": "e274f632-ef5e-4ed4-959f-ecd1de991aa0", + "userId": "fiction-user-5", "firstName": "Reese", "lastName": "Williams", "avatarUrl": null, @@ -4640,7 +4640,7 @@ "text": "Loved the story, will read again." }, { - "userId": "f7719e3a-7ece-4a85-a8cc-7326a73b1a14", + "userId": "non-fiction-user-2", "firstName": "Kendall", "lastName": "Sanchez", "avatarUrl": null, @@ -4650,7 +4650,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "7bba413f-ebce-42ba-8180-5a669eb0bfdf", + "userId": "non-fiction-user-3", "firstName": "Morgan", "lastName": "Taylor", "avatarUrl": null, @@ -4660,7 +4660,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "bf4a9d5a-e619-4579-a297-a93be64b1db8", + "userId": "non-fiction-user-5", "firstName": "Baylor", "lastName": "Martinez", "avatarUrl": null, @@ -4670,7 +4670,7 @@ "text": "Loved the story, will read again." }, { - "userId": "77317f6a-d14f-4026-8ae7-5300df77f231", + "userId": "non-fiction-user-3", "firstName": "Kai", "lastName": "Johnson", "avatarUrl": null, @@ -4680,7 +4680,7 @@ "text": "This was okay overall, but not really my taste." }, { - "userId": "96b2b86d-e02f-4607-a883-53dca37418e7", + "userId": "non-fiction-user-2", "firstName": "Rowan", "lastName": "Rodriguez", "avatarUrl": null, @@ -4690,7 +4690,7 @@ "text": "Loved the story, will read again." }, { - "userId": "bf936e01-5f59-4731-8e4f-ce8f6a05e3f6", + "userId": "mixed-reader-4", "firstName": "Parker", "lastName": "Garcia", "avatarUrl": null, @@ -4700,7 +4700,7 @@ "text": "Loved the story, will read again." }, { - "userId": "d62765df-6d30-4022-a404-3e017c1c2c49", + "userId": "mixed-reader-1", "firstName": "Cameron", "lastName": "Williams", "avatarUrl": null, @@ -4710,7 +4710,7 @@ "text": "Loved the story, will read again." }, { - "userId": "497a8725-ba02-45d0-a3de-7abd3fe51939", + "userId": "non-fiction-user-3", "firstName": "Dallas", "lastName": "Anderson", "avatarUrl": null, @@ -4720,7 +4720,7 @@ "text": "Too long and too slow for my preference." }, { - "userId": "4e18f670-0224-4868-a2c1-bbcaef99b997", + "userId": "non-fiction-user-2", "firstName": "Riley", "lastName": "Perez", "avatarUrl": null, @@ -4730,7 +4730,7 @@ "text": "Too long and too slow for my preference." }, { - "userId": "a847876f-4865-4e82-8440-921e303fd68c", + "userId": "non-fiction-user-5", "firstName": "Blake", "lastName": "Hernandez", "avatarUrl": null, @@ -4740,7 +4740,7 @@ "text": "Too long and too slow for my preference." }, { - "userId": "832f4a97-edd2-49b2-9d85-3ec206861228", + "userId": "non-fiction-user-5", "firstName": "Jamie", "lastName": "Davis", "avatarUrl": null, @@ -4750,7 +4750,7 @@ "text": "Boring and uninteresting." }, { - "userId": "3f71164b-e7bc-42e3-9f13-d5c406bdf545", + "userId": "non-fiction-user-4", "firstName": "Blake", "lastName": "White", "avatarUrl": null, @@ -4760,7 +4760,7 @@ "text": "Not into it, but the style is nice." }, { - "userId": "d97ade1c-ece9-4e8f-bf92-0b04f941b193", + "userId": "non-fiction-user-3", "firstName": "Phoenix", "lastName": "Rodriguez", "avatarUrl": null, @@ -4770,7 +4770,7 @@ "text": "Boring and uninteresting." }, { - "userId": "efdbd61b-cbf8-4f67-91e8-14fca17a827f", + "userId": "non-fiction-user-3", "firstName": "Dallas", "lastName": "Brown", "avatarUrl": null, @@ -4780,7 +4780,7 @@ "text": "Loved the story, will read again." }, { - "userId": "7a8259b2-2d55-42b8-88fd-47b5cf39ea8f", + "userId": "fiction-user-4", "firstName": "Drew", "lastName": "Brown", "avatarUrl": null, @@ -4790,7 +4790,7 @@ "text": "Loved the story, will read again." }, { - "userId": "bd0c23b1-f4c6-4c18-aa10-2f55ff639fa4", + "userId": "non-fiction-user-4", "firstName": "Taylor", "lastName": "Jones", "avatarUrl": null, @@ -4800,7 +4800,7 @@ "text": "Not into it, but the style is nice." }, { - "userId": "0e4617bc-8a76-472f-af39-766fdb017cb2", + "userId": "non-fiction-user-1", "firstName": "Blake", "lastName": "Thomas", "avatarUrl": null, @@ -4810,7 +4810,7 @@ "text": "This was okay overall, but not really my taste." }, { - "userId": "45b114e0-f04d-4d28-9ee0-f96d51092167", + "userId": "non-fiction-user-3", "firstName": "Parker", "lastName": "Davis", "avatarUrl": null, @@ -4820,7 +4820,7 @@ "text": "Too long and too slow for my preference." }, { - "userId": "a9d99ed6-e117-4578-92a9-a2431f4bf272", + "userId": "fiction-user-2", "firstName": "Riley", "lastName": "Rodriguez", "avatarUrl": null, @@ -4830,7 +4830,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "ceb11e28-284c-4d04-b33c-14447b79990e", + "userId": "mixed-reader-3", "firstName": "Baylor", "lastName": "Perez", "avatarUrl": null, @@ -4840,7 +4840,7 @@ "text": "Too long and too slow for my preference." }, { - "userId": "d7586f65-10eb-49d6-9cec-9e265e54d735", + "userId": "non-fiction-user-2", "firstName": "Dallas", "lastName": "Hernandez", "avatarUrl": null, @@ -4850,7 +4850,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "bbfca3bf-9790-4aaa-b4e2-7b501e63006c", + "userId": "non-fiction-user-1", "firstName": "Taylor", "lastName": "Martin", "avatarUrl": null, @@ -4860,7 +4860,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "70acf991-d0ef-41c2-b9fb-9203b62a93e4", + "userId": "non-fiction-user-5", "firstName": "Hayden", "lastName": "Jones", "avatarUrl": null, @@ -4870,7 +4870,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "575b17cf-2b4d-4de0-8ed6-7cbc7559c235", + "userId": "non-fiction-user-2", "firstName": "Reese", "lastName": "Garcia", "avatarUrl": null, @@ -4880,7 +4880,7 @@ "text": "Amazing pace and character development." }, { - "userId": "47bc35bb-a7ad-4a22-91db-788bac9282b7", + "userId": "mixed-reader-5", "firstName": "Devon", "lastName": "Lee", "avatarUrl": null, @@ -4890,7 +4890,7 @@ "text": "Not into it, but the style is nice." }, { - "userId": "030868bb-a9a0-426d-90a3-cd683bc81c13", + "userId": "biography-user-5", "firstName": "Cameron", "lastName": "Davis", "avatarUrl": null, @@ -4900,7 +4900,7 @@ "text": "Too long and too slow for my preference." }, { - "userId": "3e0c6ce4-a608-4ece-8f8f-d45aa2da4e0b", + "userId": "biography-user-5", "firstName": "Jamie", "lastName": "Lee", "avatarUrl": null, @@ -4910,7 +4910,7 @@ "text": "Boring and uninteresting." }, { - "userId": "85ed18b4-bde0-4039-bbda-1029c8b0271f", + "userId": "non-fiction-user-1", "firstName": "Morgan", "lastName": "Thompson", "avatarUrl": null, @@ -4920,7 +4920,7 @@ "text": "Amazing pace and character development." }, { - "userId": "04f01725-7c37-48a7-85c2-158a1b35b2bb", + "userId": "non-fiction-user-3", "firstName": "Dallas", "lastName": "Harris", "avatarUrl": null, @@ -4930,7 +4930,7 @@ "text": "Amazing pace and character development." }, { - "userId": "dc52b7a5-08e5-4047-bd19-093ce236d4c2", + "userId": "non-fiction-user-3", "firstName": "Parker", "lastName": "Perez", "avatarUrl": null, @@ -4940,7 +4940,7 @@ "text": "Not into it, but the style is nice." }, { - "userId": "64608eda-d0a1-47f7-9497-99b348196815", + "userId": "fiction-user-3", "firstName": "Emerson", "lastName": "Thompson", "avatarUrl": null, @@ -4950,7 +4950,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "0c7f8c60-e26f-40cc-90d6-fa4970e3f6d8", + "userId": "fiction-user-4", "firstName": "Morgan", "lastName": "Hernandez", "avatarUrl": null, @@ -4960,7 +4960,7 @@ "text": "Amazing pace and character development." }, { - "userId": "c03b2443-089b-40aa-8d20-992572cb8eda", + "userId": "non-fiction-user-1", "firstName": "Hayden", "lastName": "Anderson", "avatarUrl": null, @@ -4970,7 +4970,7 @@ "text": "Loved the story, will read again." }, { - "userId": "14264b32-e288-44e7-abe9-f2b0a56aa0bb", + "userId": "non-fiction-user-2", "firstName": "Riley", "lastName": "Johnson", "avatarUrl": null, @@ -4980,7 +4980,7 @@ "text": "Amazing pace and character development." }, { - "userId": "05170f0c-d488-4bd2-9b8f-f6450981df8c", + "userId": "non-fiction-user-1", "firstName": "Drew", "lastName": "Thompson", "avatarUrl": null, @@ -4990,7 +4990,7 @@ "text": "Loved the story, will read again." }, { - "userId": "90843d90-976d-4487-9b2c-fc5ca7f5faa5", + "userId": "fiction-user-5", "firstName": "Rowan", "lastName": "White", "avatarUrl": null, @@ -5000,7 +5000,7 @@ "text": "Boring and uninteresting." }, { - "userId": "7693904c-d40b-44f1-8a9e-87b13d3043db", + "userId": "fiction-user-4", "firstName": "Hunter", "lastName": "Hernandez", "avatarUrl": null, @@ -5010,7 +5010,7 @@ "text": "Not into it, but the style is nice." }, { - "userId": "dac925ba-68da-4c7a-aad9-76459e92cc04", + "userId": "mixed-reader-5", "firstName": "Emerson", "lastName": "Ramirez", "avatarUrl": null, @@ -5020,7 +5020,7 @@ "text": "This was okay overall, but not really my taste." }, { - "userId": "b460cee0-b321-4c82-9f1e-048ef7d04a57", + "userId": "fiction-user-2", "firstName": "Parker", "lastName": "Clark", "avatarUrl": null, @@ -5030,7 +5030,7 @@ "text": "Boring and uninteresting." }, { - "userId": "8c4ca259-3017-47cc-919d-61e7a4751c81", + "userId": "fiction-user-5", "firstName": "Rowan", "lastName": "Clark", "avatarUrl": null, @@ -5040,7 +5040,7 @@ "text": "Loved the story, will read again." }, { - "userId": "a64be151-7768-4fd5-9bec-7a820dac8dbe", + "userId": "non-fiction-user-1", "firstName": "Taylor", "lastName": "Wilson", "avatarUrl": null, @@ -5050,7 +5050,7 @@ "text": "Loved the story, will read again." }, { - "userId": "81637caf-7e7c-4048-8b78-8f5c3e17a695", + "userId": "fiction-user-3", "firstName": "Avery", "lastName": "Martin", "avatarUrl": null, @@ -5060,7 +5060,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "266f627a-8e09-4100-b19c-fc5260c88272", + "userId": "fiction-user-4", "firstName": "Jordan", "lastName": "Johnson", "avatarUrl": null, @@ -5070,7 +5070,7 @@ "text": "Loved the story, will read again." }, { - "userId": "0ee809a0-ac17-4c70-bdca-4d613d21c1d6", + "userId": "non-fiction-user-1", "firstName": "Reese", "lastName": "White", "avatarUrl": null, @@ -5080,7 +5080,7 @@ "text": "This was okay overall, but not really my taste." }, { - "userId": "15b47675-7e7f-4ace-b66c-276160e83686", + "userId": "non-fiction-user-1", "firstName": "Reagan", "lastName": "Rodriguez", "avatarUrl": null, @@ -5090,7 +5090,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "86f4794b-6149-4765-80fd-7051f67b857e", + "userId": "non-fiction-user-4", "firstName": "Devon", "lastName": "Anderson", "avatarUrl": null, @@ -5100,7 +5100,7 @@ "text": "Boring and uninteresting." }, { - "userId": "4210bb13-9c1e-41e7-89bd-06e3aa12726f", + "userId": "non-fiction-user-5", "firstName": "Alex", "lastName": "Hernandez", "avatarUrl": null, @@ -5110,7 +5110,7 @@ "text": "Boring and uninteresting." }, { - "userId": "5ece6539-f62b-4c0a-98e5-9c612717534a", + "userId": "non-fiction-user-3", "firstName": "Reese", "lastName": "Jones", "avatarUrl": null, @@ -5120,7 +5120,7 @@ "text": "Amazing pace and character development." }, { - "userId": "dd07caff-e9f9-4f9d-ae71-ebb2de7e59dd", + "userId": "mixed-reader-5", "firstName": "Devon", "lastName": "Thompson", "avatarUrl": null, @@ -5130,7 +5130,7 @@ "text": "Too long and too slow for my preference." }, { - "userId": "18e1790c-ae95-47c3-afee-57af438bde60", + "userId": "non-fiction-user-5", "firstName": "Taylor", "lastName": "Clark", "avatarUrl": null, @@ -5140,7 +5140,7 @@ "text": "Amazing pace and character development." }, { - "userId": "eb7fcdd8-ee5b-4efe-a5d6-92ea213c8e28", + "userId": "non-fiction-user-4", "firstName": "Reagan", "lastName": "Johnson", "avatarUrl": null, @@ -5150,7 +5150,7 @@ "text": "Too long and too slow for my preference." }, { - "userId": "ed0abc92-6b06-4c76-af5c-91614eb7c812", + "userId": "non-fiction-user-5", "firstName": "Marley", "lastName": "Williams", "avatarUrl": null, @@ -5160,7 +5160,7 @@ "text": "Boring and uninteresting." }, { - "userId": "db6f3ec0-0519-47cf-b164-8bd13416c461", + "userId": "technology-user-3", "firstName": "Drew", "lastName": "Clark", "avatarUrl": null, @@ -5170,7 +5170,7 @@ "text": "Not into it, but the style is nice." }, { - "userId": "ec72b51f-c237-4cf9-aa09-c4cf804b1eb1", + "userId": "technology-user-2", "firstName": "Kai", "lastName": "Sanchez", "avatarUrl": null, @@ -5180,7 +5180,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "d9c10806-bc6b-46e0-8013-248ef6aa5778", + "userId": "technology-user-5", "firstName": "Rowan", "lastName": "Martinez", "avatarUrl": null, @@ -5190,7 +5190,7 @@ "text": "Not into it, but the style is nice." }, { - "userId": "4bb36d30-7230-4d08-ba7c-c87fd85a13b9", + "userId": "technology-user-4", "firstName": "Kai", "lastName": "Rodriguez", "avatarUrl": null, @@ -5200,7 +5200,7 @@ "text": "Not into it, but the style is nice." }, { - "userId": "13f4cf04-648a-451e-a5cb-ce0759dadefc", + "userId": "technology-user-3", "firstName": "Hunter", "lastName": "Thompson", "avatarUrl": null, @@ -5210,7 +5210,7 @@ "text": "Boring and uninteresting." }, { - "userId": "ff8bb96b-f8db-4af0-b871-b581abaafd04", + "userId": "technology-user-1", "firstName": "Skyler", "lastName": "Wilson", "avatarUrl": null, @@ -5220,7 +5220,7 @@ "text": "This was okay overall, but not really my taste." }, { - "userId": "464f9550-2b00-4bff-bab7-674c65a7aa57", + "userId": "technology-user-4", "firstName": "Jordan", "lastName": "Wilson", "avatarUrl": null, @@ -5230,7 +5230,7 @@ "text": "Amazing pace and character development." }, { - "userId": "cf3ba958-2258-4445-b159-f98da3f39eb9", + "userId": "mixed-reader-1", "firstName": "Riley", "lastName": "Thompson", "avatarUrl": null, @@ -5240,7 +5240,7 @@ "text": "Boring and uninteresting." }, { - "userId": "cddce724-6d9b-4e3a-a194-24025dacad07", + "userId": "technology-user-3", "firstName": "Phoenix", "lastName": "Johnson", "avatarUrl": null, @@ -5250,7 +5250,7 @@ "text": "Not into it, but the style is nice." }, { - "userId": "951d3a14-d088-4b94-804c-31933a4674ad", + "userId": "technology-user-2", "firstName": "Alex", "lastName": "Anderson", "avatarUrl": null, @@ -5260,7 +5260,7 @@ "text": "Amazing pace and character development." }, { - "userId": "bd6bbc83-81ff-472f-a68d-bd585457f4a5", + "userId": "technology-user-3", "firstName": "Emerson", "lastName": "Garcia", "avatarUrl": null, @@ -5270,7 +5270,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "7894ef95-e1b1-4c95-a5bc-4f3838304044", + "userId": "technology-user-3", "firstName": "Alex", "lastName": "Thomas", "avatarUrl": null, @@ -5280,7 +5280,7 @@ "text": "This was okay overall, but not really my taste." }, { - "userId": "0e68abe1-a20b-4388-87b9-bd34c1d32acd", + "userId": "technology-user-4", "firstName": "Jordan", "lastName": "Ramirez", "avatarUrl": null, @@ -5290,7 +5290,7 @@ "text": "Too long and too slow for my preference." }, { - "userId": "95f75774-aec7-419f-b8c4-5b68bb028054", + "userId": "technology-user-1", "firstName": "Reagan", "lastName": "Brown", "avatarUrl": null, @@ -5300,7 +5300,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "e498946c-617f-417e-a081-1ce67155f61d", + "userId": "biography-user-4", "firstName": "Kai", "lastName": "Brown", "avatarUrl": null, @@ -5310,7 +5310,7 @@ "text": "Too long and too slow for my preference." }, { - "userId": "2c8e9c9f-d5a2-4d23-9244-3374c2150075", + "userId": "biography-user-4", "firstName": "Devon", "lastName": "Williams", "avatarUrl": null, @@ -5320,7 +5320,7 @@ "text": "Amazing pace and character development." }, { - "userId": "c7df0a7a-b958-4cfd-8722-e909035824f7", + "userId": "biography-user-3", "firstName": "Casey", "lastName": "Williams", "avatarUrl": null, @@ -5330,7 +5330,7 @@ "text": "Boring and uninteresting." }, { - "userId": "09ec676d-7ae4-429a-88cc-cbeecb087cf6", + "userId": "biography-user-2", "firstName": "Casey", "lastName": "Perez", "avatarUrl": null, @@ -5340,7 +5340,7 @@ "text": "Too long and too slow for my preference." }, { - "userId": "1ceaf0bb-2281-4782-8b95-145e958650ba", + "userId": "biography-user-5", "firstName": "Kendall", "lastName": "Davis", "avatarUrl": null, @@ -5350,7 +5350,7 @@ "text": "Boring and uninteresting." }, { - "userId": "cff58f20-e738-4e4a-8635-ce9892a1b86f", + "userId": "biography-user-1", "firstName": "Jordan", "lastName": "Harris", "avatarUrl": null, @@ -5360,7 +5360,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "c56b17b0-d772-4ebf-8026-3a633ffbdffc", + "userId": "biography-user-4", "firstName": "Logan", "lastName": "Clark", "avatarUrl": null, @@ -5370,7 +5370,7 @@ "text": "Boring and uninteresting." }, { - "userId": "88ca80e2-9118-4d60-8fbf-2dc52357a11a", + "userId": "mixed-reader-2", "firstName": "Cameron", "lastName": "Miller", "avatarUrl": null, @@ -5380,7 +5380,7 @@ "text": "This was okay overall, but not really my taste." }, { - "userId": "65b7e104-0e7d-423b-b0f3-a3c723304e61", + "userId": "biography-user-1", "firstName": "Taylor", "lastName": "Perez", "avatarUrl": null, @@ -5390,7 +5390,7 @@ "text": "This was okay overall, but not really my taste." }, { - "userId": "d340eda5-5bc5-4b61-8be4-e7ab32164030", + "userId": "biography-user-1", "firstName": "Emerson", "lastName": "Hernandez", "avatarUrl": null, @@ -5400,7 +5400,7 @@ "text": "Amazing pace and character development." }, { - "userId": "90892284-04eb-4010-a04a-b2737601c8cb", + "userId": "non-fiction-user-2", "firstName": "Baylor", "lastName": "Jones", "avatarUrl": null, @@ -5410,7 +5410,7 @@ "text": "Not into it, but the style is nice." }, { - "userId": "79cf72c4-3797-499d-86f4-be32ca6b7e4a", + "userId": "mixed-reader-2", "firstName": "Casey", "lastName": "Davis", "avatarUrl": null, @@ -5420,7 +5420,7 @@ "text": "Too long and too slow for my preference." }, { - "userId": "70ff9105-86e9-4004-86fd-aab4c3996c14", + "userId": "non-fiction-user-3", "firstName": "Blake", "lastName": "Davis", "avatarUrl": null, @@ -5430,7 +5430,7 @@ "text": "This was okay overall, but not really my taste." }, { - "userId": "a31c0d6f-7c99-40a6-8631-e90069bdc3c4", + "userId": "non-fiction-user-1", "firstName": "Dallas", "lastName": "Martin", "avatarUrl": null, @@ -5440,7 +5440,7 @@ "text": "Amazing pace and character development." }, { - "userId": "eccb4174-dfe9-4324-a36d-cb1d896d99d6", + "userId": "mixed-reader-5", "firstName": "Parker", "lastName": "Miller", "avatarUrl": null, @@ -5450,7 +5450,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "b780440d-2dfe-4f00-83bc-41738de2565d", + "userId": "mixed-reader-4", "firstName": "Dallas", "lastName": "Miller", "avatarUrl": null, @@ -5460,7 +5460,7 @@ "text": "Boring and uninteresting." }, { - "userId": "34712ed9-2ec9-4907-98b3-01625e8b5c37", + "userId": "non-fiction-user-2", "firstName": "Marley", "lastName": "Johnson", "avatarUrl": null, @@ -5470,7 +5470,7 @@ "text": "Boring and uninteresting." }, { - "userId": "f3b8a70b-8c8d-43f3-aace-44600a4dce0a", + "userId": "non-fiction-user-5", "firstName": "Quinn", "lastName": "Hernandez", "avatarUrl": null, @@ -5480,7 +5480,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "ef85d065-e25c-406f-8f31-a74d5b25162f", + "userId": "non-fiction-user-4", "firstName": "Parker", "lastName": "Thompson", "avatarUrl": null, @@ -5490,7 +5490,7 @@ "text": "Loved the story, will read again." }, { - "userId": "d2a7b883-c2e2-4aad-8600-7703bec6757e", + "userId": "non-fiction-user-2", "firstName": "Baylor", "lastName": "Lee", "avatarUrl": null, @@ -5500,7 +5500,7 @@ "text": "Loved the story, will read again." }, { - "userId": "b5488808-48a4-42bd-a266-40457e60b4af", + "userId": "non-fiction-user-5", "firstName": "Avery", "lastName": "Taylor", "avatarUrl": null, @@ -5510,7 +5510,7 @@ "text": "Too long and too slow for my preference." }, { - "userId": "4891d83c-91bc-4ee5-bfda-33bbcda0a347", + "userId": "non-fiction-user-2", "firstName": "Finley", "lastName": "Brown", "avatarUrl": null, @@ -5520,7 +5520,7 @@ "text": "Not into it, but the style is nice." }, { - "userId": "a9869556-8fce-4b93-95ce-2984f8c798e9", + "userId": "non-fiction-user-4", "firstName": "Quinn", "lastName": "Moore", "avatarUrl": null, @@ -5530,7 +5530,7 @@ "text": "This was okay overall, but not really my taste." }, { - "userId": "c4ec0383-d077-46f3-8dc0-b329970b16e7", + "userId": "non-fiction-user-1", "firstName": "Kendall", "lastName": "Clark", "avatarUrl": null, @@ -5540,7 +5540,7 @@ "text": "Amazing pace and character development." }, { - "userId": "d5773098-e2d4-41ef-aaf3-9e07cd79f69f", + "userId": "biography-user-1", "firstName": "Emerson", "lastName": "Martin", "avatarUrl": null, @@ -5550,7 +5550,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "7563acbd-86fe-49c8-8236-0d5d95455e41", + "userId": "biography-user-2", "firstName": "Avery", "lastName": "Ramirez", "avatarUrl": null, @@ -5560,7 +5560,7 @@ "text": "Great book, everything was perfect!" }, { - "userId": "b51f8fb1-ed7b-4052-8796-30d515f09c48", + "userId": "technology-user-4", "firstName": "Dallas", "lastName": "Sanchez", "avatarUrl": null, @@ -5570,7 +5570,7 @@ "text": "Not into it, but the style is nice." }, { - "userId": "2107871f-8c8d-434e-8541-0d7cb7c41041", + "userId": "technology-user-2", "firstName": "Alex", "lastName": "Lee", "avatarUrl": null, diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 47ed6e2..2c56799 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -20,6 +20,7 @@ services: build: /bookService environment: - SPRING_PROFILES_ACTIVE=prod + - RECOMMENDER_SERVICE_URL=http://recommender-service:8082 container_name: book-service ports: - "8080:8080" @@ -28,6 +29,8 @@ services: depends_on: mysql: condition: service_healthy + recommender-service: + condition: service_healthy networks: - e-library-network @@ -74,6 +77,29 @@ services: networks: - e-library-network + recommender-service: + build: + context: ./recommenderService + dockerfile: Dockerfile + container_name: recommender-service + ports: + - "8082:8082" + environment: + - ML_MODELS_DIR=/mnt/models + - LOG_LEVEL=INFO + - RECOMMENDER_SERVICE_PORT=8082 + - ENVIRONMENT=production + volumes: + - ./data/ml_models:/mnt/models + networks: + - e-library-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8082/api/v1/recommendations/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + prometheus: container_name: prometheus image: prom/prometheus:latest diff --git a/docker-compose.yml b/docker-compose.yml index d791088..dccf8de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,7 +45,7 @@ services: ports: - "27018:27017" volumes: - - mongodb-dev_data:/data/db + - ./docker/mongodb-dev/data:/data/db healthcheck: test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/test --quiet networks: @@ -63,6 +63,7 @@ services: KC_DB_PASSWORD: password KEYCLOAK_ADMIN: admin KEYCLOAK_ADMIN_PASSWORD: admin + JAVA_OPTS_APPEND: -Djboss.as.management.blocking.timeout=3600 ports: - "8181:8080" volumes: @@ -108,11 +109,17 @@ services: depends_on: - broker ports: - - "8082:8082" + - "8085:8085" environment: SCHEMA_REGISTRY_HOST_NAME: schema-registry SCHEMA_REGISTRY_KAFKASTORE_BOOTSTRAP_SERVERS: 'broker:29092' - SCHEMA_REGISTRY_LISTENERS: http://0.0.0.0:8082 + SCHEMA_REGISTRY_LISTENERS: http://0.0.0.0:8085 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8085/subjects"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s networks: - e-library-network @@ -127,11 +134,35 @@ services: environment: KAFKA_CLUSTERS_NAME: local KAFKA_CLUSTERS_BOOTSTRAPSERVERS: broker:29092 - KAFKA_CLUSTERS_SCHEMAREGISTRY: http://schema-registry:8082 + KAFKA_CLUSTERS_SCHEMAREGISTRY: http://schema-registry:8085 DYNAMIC_CONFIG_ENABLED: 'true' networks: - e-library-network + recommender-service: + build: + context: ./recommenderService + dockerfile: Dockerfile + container_name: recommender-service + ports: + - "8082:8082" + environment: + - ML_MODELS_DIR=/mnt/models + - LOG_LEVEL=INFO + - RECOMMENDER_SERVICE_PORT=8082 + - KAFKA_BOOTSTRAP_SERVERS=broker:29092 + - KAFKA_SCHEMA_REGISTRY_URL=http://schema-registry:8085 + volumes: + - ./data/ml_models:/mnt/models + networks: + - e-library-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8082/api/v1/recommendations/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + prometheus: container_name: prometheus image: prom/prometheus:latest @@ -169,4 +200,3 @@ networks: volumes: mysql-dev_data: kc-mysql_data: - mongodb-dev_data: diff --git a/infrastructure/keycloak/e-library-realm.json b/infrastructure/keycloak/e-library-realm.json index 534ee12..91c85a9 100644 --- a/infrastructure/keycloak/e-library-realm.json +++ b/infrastructure/keycloak/e-library-realm.json @@ -82,6 +82,7 @@ "composite": true, "composites": { "realm": [ + "ROLE_USER", "offline_access", "uma_authorization" ], @@ -706,6 +707,7 @@ "alwaysDisplayInConsole": false, "clientAuthenticatorType": "client-secret", "redirectUris": [ + "http://localhost:9000/swagger-ui/*", "http://localhost:4200/*" ], "webOrigins": [ @@ -723,13 +725,20 @@ "frontchannelLogout": true, "protocol": "openid-connect", "attributes": { - "oidc.ciba.grant.enabled": "false", "client.secret.creation.time": "1747844036", - "backchannel.logout.session.required": "true", "post.logout.redirect.uris": "+", - "display.on.consent.screen": "false", "oauth2.device.authorization.grant.enabled": "false", - "backchannel.logout.revoke.offline.tokens": "false" + "backchannel.logout.revoke.offline.tokens": "false", + "use.refresh.tokens": "true", + "oidc.ciba.grant.enabled": "false", + "client.use.lightweight.access.token.enabled": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "acr.loa.map": "{}", + "require.pushed.authorization.requests": "false", + "tls.client.certificate.bound.access.tokens": "false", + "display.on.consent.screen": "false", + "token.response.type.bearer.lower-case": "false" }, "authenticationFlowBindingOverrides": {}, "fullScopeAllowed": true, @@ -1636,7 +1645,7 @@ ], "identityProviderMappers": [ { - "id": "9d3d8869-aff3-4392-99df-42cecaa0fed6", + "id": "e6526f94-8799-4350-8261-ce7a1bb2fafb", "name": "google-avatar", "identityProviderAlias": "google", "identityProviderMapper": "google-user-attribute-mapper", @@ -1724,14 +1733,14 @@ "subComponents": {}, "config": { "allowed-protocol-mapper-types": [ - "saml-user-property-mapper", - "oidc-full-name-mapper", "saml-user-attribute-mapper", - "saml-role-list-mapper", - "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-attribute-mapper", + "saml-user-property-mapper", + "oidc-usermodel-property-mapper", "oidc-address-mapper", - "oidc-usermodel-property-mapper" + "oidc-full-name-mapper", + "saml-role-list-mapper", + "oidc-sha256-pairwise-sub-mapper" ] } }, @@ -1743,13 +1752,13 @@ "subComponents": {}, "config": { "allowed-protocol-mapper-types": [ + "saml-user-property-mapper", "oidc-full-name-mapper", "oidc-address-mapper", - "saml-user-attribute-mapper", - "saml-user-property-mapper", "oidc-usermodel-attribute-mapper", - "oidc-usermodel-property-mapper", "oidc-sha256-pairwise-sub-mapper", + "oidc-usermodel-property-mapper", + "saml-user-attribute-mapper", "saml-role-list-mapper" ] } diff --git a/recommenderService/.dockerignore b/recommenderService/.dockerignore new file mode 100644 index 0000000..ac5c473 --- /dev/null +++ b/recommenderService/.dockerignore @@ -0,0 +1,14 @@ +__pycache__ +*.pyc +*.pyo +.pytest_cache +.env +.git +.venv +venv +*.egg-info +dist +build +tests +.gitignore +*.md diff --git a/recommenderService/Dockerfile b/recommenderService/Dockerfile new file mode 100644 index 0000000..7472994 --- /dev/null +++ b/recommenderService/Dockerfile @@ -0,0 +1,27 @@ +# Stage 1: Build/Runtime +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8082/api/v1/recommendations/health || exit 1 + +# Expose port +EXPOSE 8082 + +# Run application +CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8082"] diff --git a/recommenderService/config.py b/recommenderService/config.py new file mode 100644 index 0000000..ed5821e --- /dev/null +++ b/recommenderService/config.py @@ -0,0 +1,44 @@ +import os +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +# ML Models directory (shared volume in Docker) +ML_MODELS_DIR = os.getenv("ML_MODELS_DIR", "./models") + +# Logging configuration +LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") + +# Recommendation batch size (default top-K results) +BATCH_SIZE_FOR_RECOMMENDATIONS = 10 + +# Server port +PORT = int(os.getenv("RECOMMENDER_SERVICE_PORT", 8082)) +HOST = "0.0.0.0" + +# Enable local fallback to bookService popular_books.json +ENABLE_LOCAL_FALLBACK = True + +# Environment +ENV = os.getenv("ENVIRONMENT", "development") + +# Kafka Configuration (Event-driven review scoring) +KAFKA_BOOTSTRAP_SERVERS = os.getenv("KAFKA_BOOTSTRAP_SERVERS", "localhost:9092").split(",") +KAFKA_SCHEMA_REGISTRY_URL = os.getenv("KAFKA_SCHEMA_REGISTRY_URL", "http://localhost:8085") + +# Kafka Topics +KAFKA_REVIEW_SCORING_REQUEST_TOPIC = os.getenv("KAFKA_REVIEW_SCORING_REQUEST_TOPIC", "review-scoring-requests") +KAFKA_REVIEW_SCORING_RESULT_TOPIC = os.getenv("KAFKA_REVIEW_SCORING_RESULT_TOPIC", "review-scoring-results") +KAFKA_REVIEW_SCORING_DLQ_TOPIC = os.getenv("KAFKA_REVIEW_SCORING_DLQ_TOPIC", "review-scoring-dlq") + +# Kafka Consumer Group +KAFKA_CONSUMER_GROUP = os.getenv("KAFKA_CONSUMER_GROUP", "recommender-service-group") + +# Batch processing settings (for future optimization) +KAFKA_BATCH_SIZE = int(os.getenv("KAFKA_BATCH_SIZE", "1")) +KAFKA_BATCH_TIMEOUT_MS = int(os.getenv("KAFKA_BATCH_TIMEOUT_MS", "1000")) + +# Sentence transformer model for relevance scoring +SENTENCE_TRANSFORMER_MODEL = os.getenv("SENTENCE_TRANSFORMER_MODEL", "all-MiniLM-L6-v2") +SENTENCE_TRANSFORMER_MODEL_VERSION = "3.0.0" diff --git a/recommenderService/main.py b/recommenderService/main.py new file mode 100644 index 0000000..4e5fd24 --- /dev/null +++ b/recommenderService/main.py @@ -0,0 +1,108 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +import os +import asyncio +from config import HOST, PORT, ML_MODELS_DIR +from utils.logger import logger +from services.ml_model_loader import MLModelLoader +from services.content_based_service import ContentBasedRecommender +from services.collaborative_service import CollaborativeRecommender +from services.hybrid_recommender import HybridRecommender +from services.kafka_consumer import start_review_scoring_consumer, stop_review_scoring_consumer +from routes import recommendations + +# Initialize ML loader as a module-level variable +ml_loader = MLModelLoader(models_dir=ML_MODELS_DIR) +recommender = None +kafka_consumer_task = None + +# Create FastAPI application +app = FastAPI( + title="e-library Recommender Service", + version="1.0.0", + description="Hybrid recommendation engine (content-based + collaborative filtering)" +) + +# Add CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.on_event("startup") +async def startup_event(): + """Initialize ML models and start Kafka consumer on startup""" + global ml_loader, recommender, kafka_consumer_task + + logger.info("=" * 50) + logger.info("Starting recommenderService...") + logger.info("=" * 50) + + try: + # Load ML models + ml_loader.load_all_models() + + if not ml_loader.is_ready(): + raise RuntimeError("Failed to initialize ML models") + + # Initialize recommendation engines + content_based = ContentBasedRecommender(ml_loader) + collaborative = CollaborativeRecommender(ml_loader) + recommender = HybridRecommender(content_based, collaborative) + + # Set recommender context in routes + recommendations.set_recommender_context(ml_loader, recommender) + + # Start Kafka consumer for review scoring requests (async background task) + kafka_consumer_task = asyncio.create_task(start_review_scoring_consumer()) + logger.info("Review scoring consumer started in background") + + logger.info("=" * 50) + logger.info("recommenderService ready for requests") + logger.info("=" * 50) + + except Exception as e: + logger.error(f"Failed to start service: {e}") + raise + + +@app.on_event("shutdown") +async def shutdown_event(): + """Cleanup on shutdown""" + global kafka_consumer_task + logger.info("Shutting down recommenderService") + + # Stop Kafka consumer gracefully + try: + await stop_review_scoring_consumer() + except Exception as e: + logger.error(f"Error stopping Kafka consumer: {e}", exc_info=True) + + +@app.get("/") +async def root(): + """Root endpoint""" + return { + "service": "recommenderService", + "status": "running", + "version": "1.0.0" + } + + +# Include routers +app.include_router(recommendations.router) + + +if __name__ == "__main__": + import uvicorn + logger.info(f"Starting FastAPI server on {HOST}:{PORT}") + uvicorn.run( + app, + host=HOST, + port=PORT, + log_level="info" + ) diff --git a/recommenderService/models/__init__.py b/recommenderService/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/recommenderService/models/request_response_models.py b/recommenderService/models/request_response_models.py new file mode 100644 index 0000000..ff90e4b --- /dev/null +++ b/recommenderService/models/request_response_models.py @@ -0,0 +1,62 @@ +from pydantic import BaseModel, Field +from typing import List, Optional, Dict, Any +from enum import Enum + + +class ExplanationReasonType(str, Enum): + """Types of recommendation explanations""" + TF_IDF_MATCH = "TF_IDF_MATCH" + COLLABORATIVE_FILTER = "COLLABORATIVE_FILTER" + + +class ExplanationDetails(BaseModel): + """Why a book was recommended""" + primaryReason: str + reasonType: ExplanationReasonType + topContributors: List[str] = Field(default_factory=list) # Keywords or seed book IDs + confidenceScore: float + details: Optional[Dict[str, Any]] = None + + +class SimilarBookResponse(BaseModel): + """Response model for similar books""" + bookId: int + similarityScore: float + explanation: Optional[ExplanationDetails] = None + + +class PersonalizedRecommendationResponse(BaseModel): + """Response model for personalized recommendations""" + bookId: int + score: float + explanation: Optional[ExplanationDetails] = None + seedBookIds: Optional[List[int]] = None + + +class GetSimilarBooksRequest(BaseModel): + """Request model for getting similar books""" + bookId: int = Field(..., description="The book ID to find similar books for") + topK: int = Field(default=10, ge=1, le=50, description="Number of recommendations") + + +class GetPersonalizedRecommendationsRequest(BaseModel): + """Request model for getting personalized recommendations""" + userSeedBooks: List[int] = Field( + ..., + description="List of book IDs user has reviewed" + ) + topK: int = Field(default=10, ge=1, le=50, description="Number of recommendations") + + +class HealthCheckResponse(BaseModel): + """Health check response model""" + status: str + modelsLoaded: bool + modelMetadata: Optional[Dict[str, Any]] = None + + +class ErrorResponse(BaseModel): + """Error response model""" + error: str + detail: Optional[str] = None + status_code: int diff --git a/recommenderService/offline_evaluation.py b/recommenderService/offline_evaluation.py new file mode 100644 index 0000000..d3deaba --- /dev/null +++ b/recommenderService/offline_evaluation.py @@ -0,0 +1,1051 @@ +#!/usr/bin/env python3 +""" +Task 2.7: Offline Evaluation Script for Review Relevance Scoring System + +Purpose: + Validate review relevance scoring model quality on ground truth dataset + before integrating with live system. + +Usage: + python offline_evaluation.py \\ + --dataset phase1_datasets/ground_truth_evaluation_set.csv \\ + --model all-MiniLM-L6-v2 \\ + --output_dir evaluation_results/ \\ + --sample_size 10000 \\ + --score_threshold 0.6 + +Output: + - evaluation_report.html (interactive report) + - evaluation_metrics.json (machine-readable results) + - evaluation_log.txt (detailed processing log) + - flagged_outliers.csv (anomalous scores) + +Status: Phase 2, Task 2.7 - COMPLETE +""" + +import argparse +import csv +import json +import logging +import sys +import time +from dataclasses import dataclass, asdict +from datetime import datetime +from pathlib import Path +from typing import List, Tuple, Optional, Dict, Any +import math +from collections import defaultdict +import statistics + +import numpy as np +from sentence_transformers import SentenceTransformer +from sklearn.metrics import ( + precision_score, recall_score, f1_score, accuracy_score, + confusion_matrix, roc_auc_score, roc_curve +) + +# ============================================================================ +# Configuration & Data Classes +# ============================================================================ + +@dataclass +class EvaluationConfig: + """Configuration for offline evaluation""" + dataset_path: Path + model_name: str + output_dir: Path + sample_size: int + score_threshold: float + quick_test: bool = False + log_level: str = "INFO" + + def __post_init__(self): + """Validate configuration""" + if not self.dataset_path.exists(): + raise FileNotFoundError(f"Dataset not found: {self.dataset_path}") + self.output_dir.mkdir(parents=True, exist_ok=True) + + +@dataclass +class ScoringRecord: + """Single book-review pair scoring result""" + book_id: int + review_id: str + book_text: str # Aggregated metadata (Task 2.1) + review_text: str # Raw text + review_text_preprocessed: str # After Task 2.3 pipeline + book_embedding: np.ndarray + review_embedding: np.ndarray + raw_cosine_similarity: float + relevance_score: float # Normalized [0.0, 1.0] + confidence: float # [0.0, 1.0] + ground_truth_label: str # "RELEVANT" | "IRRELEVANT" + processing_time_ms: float + review_length: int + book_length: int + + +@dataclass +class EvaluationMetrics: + """Complete evaluation results""" + # Distribution metrics + mean_score: float + median_score: float + std_dev: float + min_score: float + max_score: float + score_histogram: Dict[str, int] # "0.0-0.1": count, ... + + # Quality metrics at threshold + threshold: float + accuracy: float + precision: float + recall: float + f1: float + roc_auc: float + + # Confusion matrix + true_positives: int + true_negatives: int + false_positives: int + false_negatives: int + + # Performance metrics + p50_latency_ms: float + p95_latency_ms: float + p99_latency_ms: float + total_runtime_sec: float + throughput_per_sec: float + + # Correlation metrics + correlation_score_to_label: float + + # Edge cases + null_score_count: int + short_review_count: int + long_review_count: int + poor_metadata_count: int + + # Pass/fail + passes_criteria: bool + failure_reasons: List[str] + + +# ============================================================================ +# Core Scoring Functions (from Tasks 2.1-2.5) +# ============================================================================ + +class BookMetadataAggregator: + """Task 2.1: Aggregate book metadata using Strategy C""" + + @staticmethod + def aggregate_from_dict(book_data: Dict[str, Any]) -> str: + """ + Aggregate book metadata (Strategy C: Selective Fields with Priority) + + Order: Title → Author → Genres → Category → Description + Skip NULL fields entirely + + Args: + book_data: dict with keys: title, author, genres, category, description + + Returns: + Aggregated text (max ~500 chars) + """ + parts = [] + + # Always include title + if book_data.get('title'): + parts.append(f"Title: {book_data['title']}") + + # Author (high signal) + if book_data.get('author'): + parts.append(f"Author: {book_data['author']}") + + # Genres (categorical signal) + if book_data.get('genres'): + genres = book_data['genres'] + if isinstance(genres, list): + genres = ', '.join(genres) + if genres: + parts.append(f"Genres: {genres}") + + # Category (categorical signal) + if book_data.get('category'): + parts.append(f"Category: {book_data['category']}") + + # Description (most semantic-rich, prioritize) + if book_data.get('description'): + parts.append(f"Description: {book_data['description']}") + + return '\n'.join(parts) + + +class ReviewTextPreprocessor: + """Task 2.3: Preprocess review text (7-step pipeline)""" + + @staticmethod + def preprocess(text: str) -> str: + """ + Preprocess review text using 7-step pipeline + + Steps: + 1. HTML decode (& → &, etc.) + 2. Lowercase + 3. Remove emojis/unicode + 4. Normalize whitespace + 5. Remove spoiler markers + 6. Collapse repeated punctuation + 7. Truncate at 1800 chars or 450 tokens + + Args: + text: Raw review text + + Returns: + Preprocessed text + """ + if not text: + return "" + + # Step 1: HTML decode + import html + text = html.unescape(text) + + # Step 2: Lowercase + text = text.lower() + + # Step 3: Remove emojis/unicode (keep ASCII + common punctuation) + text = ''.join( + c if ord(c) < 128 or c in '\n\t' else '' + for c in text + ) + + # Step 4: Normalize whitespace + text = ' '.join(text.split()) + + # Step 5: Remove spoiler markers + spoiler_patterns = [ + 'spoiler alert:', 'spoiler:', 'spoilers:', + 'trigger warning:', 'content warning:' + ] + for pattern in spoiler_patterns: + text = text.replace(pattern, '') + + # Step 6: Collapse repeated punctuation + while '!!' in text: + text = text.replace('!!', '!') + while '??' in text: + text = text.replace('??', '?') + while '..' in text: + text = text.replace('..', '.') + + # Step 7: Truncate at sentence boundary (~450 tokens = ~1800 chars) + max_chars = 1800 + if len(text) > max_chars: + # Find last sentence boundary + text = text[:max_chars] + last_period = max( + text.rfind('.'), + text.rfind('!'), + text.rfind('?') + ) + if last_period > max_chars - 200: # If near end + text = text[:last_period + 1] + + return text + + +class RelevanceScoringFunction: + """Task 2.5: Core relevance scoring function""" + + @staticmethod + def score_relevance( + book_embedding: np.ndarray, + review_embedding: np.ndarray, + text_quality: float = 0.5 + ) -> Tuple[float, float, float]: + """ + Compute relevance score from embeddings via cosine similarity + + Args: + book_embedding: Sentence transformer embedding for book + review_embedding: Sentence transformer embedding for review + text_quality: Quality score [0, 1] for confidence computation + + Returns: + Tuple of (relevance_score, raw_cosine_similarity, confidence) + - relevance_score: [0.0, 1.0] normalized + - raw_cosine_similarity: [-1.0, 1.0] before normalization + - confidence: [0.0, 1.0] confidence in score + """ + # Input validation + if book_embedding is None or review_embedding is None: + return None, None, None + + if len(book_embedding) == 0 or len(review_embedding) == 0: + return None, None, None + + # Compute cosine similarity + norm_book = np.linalg.norm(book_embedding) + norm_review = np.linalg.norm(review_embedding) + + if norm_book == 0 or norm_review == 0: + return None, None, None + + raw_cosine = np.dot(book_embedding, review_embedding) / (norm_book * norm_review) + raw_cosine = float(np.clip(raw_cosine, -1.0, 1.0)) + + # Normalize to [0, 1] + relevance_score = (raw_cosine + 1.0) / 2.0 + + # Confidence: high when |cosine_sim| is high (strong signal) + confidence = (abs(raw_cosine) * 0.7) + (text_quality * 0.3) + confidence = float(np.clip(confidence, 0.0, 1.0)) + + return relevance_score, raw_cosine, confidence + + +# ============================================================================ +# Dataset Loading +# ============================================================================ + +class GroundTruthDataset: + """Load and manage ground truth evaluation dataset""" + + def __init__(self, csv_path: Path, sample_size: int = 10000): + """ + Load ground truth dataset from CSV + + Expected columns: + - book_id: int + - review_id: string (UUID) + - book_text: string (aggregated metadata) + - review_text: string (raw review) + - review_rating: float (1-5) + - relevance_label: string ("RELEVANT" | "IRRELEVANT") + + Args: + csv_path: Path to ground_truth_evaluation_set.csv + sample_size: Max records to load (10000 for full, 100 for quick test) + """ + self.csv_path = csv_path + self.records = [] + self._load(sample_size) + + def _load(self, max_records: int): + """Load dataset from CSV""" + with open(self.csv_path, 'r', encoding='utf-8') as f: + reader = csv.DictReader(f) + for i, row in enumerate(reader): + if i >= max_records: + break + + self.records.append({ + 'book_id': int(row['book_id']), + 'review_id': row['review_id'], + 'book_text': row.get('book_text', ''), + 'review_text': row.get('review_text', ''), + 'review_rating': float(row.get('review_rating', 3.0)), + 'relevance_label': row['relevance_label'], # Ground truth + }) + + def __len__(self) -> int: + return len(self.records) + + def __iter__(self): + return iter(self.records) + + +# ============================================================================ +# Evaluation Engine +# ============================================================================ + +class OfflineEvaluationEngine: + """Main evaluation orchestrator""" + + def __init__(self, config: EvaluationConfig, logger: logging.Logger): + self.config = config + self.logger = logger + self.model: Optional[SentenceTransformer] = None + self.scoring_records: List[ScoringRecord] = [] + self.metrics: Optional[EvaluationMetrics] = None + + def run(self) -> EvaluationMetrics: + """Execute full evaluation pipeline""" + start_time = time.time() + self.logger.info(f"Starting evaluation: {self.config.sample_size} samples") + + try: + # Step 1: Load model + self._load_model() + + # Step 2: Load dataset + dataset = self._load_dataset() + + # Step 3: Score all samples + self._score_all_samples(dataset) + + # Step 4: Compute metrics + self.metrics = self._compute_metrics() + + # Step 5: Generate report + self._generate_report() + + total_time = time.time() - start_time + self.logger.info(f"Evaluation complete in {total_time:.1f}s") + + return self.metrics + + except Exception as e: + self.logger.error(f"Evaluation failed: {e}", exc_info=True) + raise + + def _load_model(self): + """Load sentence transformer model""" + self.logger.info(f"Loading model: {self.config.model_name}") + try: + self.model = SentenceTransformer(self.config.model_name) + self.logger.info(f"Model loaded successfully") + except Exception as e: + self.logger.error(f"Failed to load model: {e}") + raise + + def _load_dataset(self) -> GroundTruthDataset: + """Load ground truth dataset""" + self.logger.info(f"Loading dataset: {self.config.dataset_path}") + dataset = GroundTruthDataset( + self.config.dataset_path, + sample_size=self.config.sample_size + ) + self.logger.info(f"Loaded {len(dataset)} samples") + return dataset + + def _score_all_samples(self, dataset: GroundTruthDataset): + """Score all book-review pairs and measure latency""" + start_time = time.time() + latencies = [] + + for i, record in enumerate(dataset): + sample_start = time.time() + + try: + # Preprocess review + review_preprocessed = ReviewTextPreprocessor.preprocess( + record['review_text'] + ) + + # Generate embeddings + book_embedding = self.model.encode(record['book_text']) + review_embedding = self.model.encode(review_preprocessed) + + # Score relevance + score, raw_cos, confidence = RelevanceScoringFunction.score_relevance( + book_embedding, + review_embedding, + text_quality=0.5 + ) + + # Record result + latency_ms = (time.time() - sample_start) * 1000 + latencies.append(latency_ms) + + self.scoring_records.append(ScoringRecord( + book_id=record['book_id'], + review_id=record['review_id'], + book_text=record['book_text'], + review_text=record['review_text'], + review_text_preprocessed=review_preprocessed, + book_embedding=book_embedding, + review_embedding=review_embedding, + raw_cosine_similarity=raw_cos if raw_cos is not None else float('nan'), + relevance_score=score if score is not None else float('nan'), + confidence=confidence if confidence is not None else float('nan'), + ground_truth_label=record['relevance_label'], + processing_time_ms=latency_ms, + review_length=len(record['review_text'].split()), + book_length=len(record['book_text'].split()), + )) + + except Exception as e: + self.logger.error(f"Failed to score sample {i}: {e}") + continue + + if (i + 1) % 100 == 0: + self.logger.info(f"Scored {i + 1}/{len(dataset)} samples") + + total_time = time.time() - start_time + self.logger.info(f"Scoring complete: {total_time:.1f}s for {len(self.scoring_records)} samples") + self.logger.info(f"Average latency: {np.mean(latencies):.1f}ms") + + def _compute_metrics(self) -> EvaluationMetrics: + """Compute all evaluation metrics""" + self.logger.info("Computing evaluation metrics...") + + # Extract data + scores = [r.relevance_score for r in self.scoring_records] + labels = [1 if r.ground_truth_label == "RELEVANT" else 0 + for r in self.scoring_records] + latencies = [r.processing_time_ms for r in self.scoring_records] + + # Remove NaN scores for metrics + valid_mask = np.array([not math.isnan(s) for s in scores]) + valid_scores = np.array(scores)[valid_mask] + valid_labels = np.array(labels)[valid_mask] + + # Distribution metrics + mean_score = float(np.mean(valid_scores)) + median_score = float(np.median(valid_scores)) + std_dev = float(np.std(valid_scores)) + min_score = float(np.min(valid_scores)) + max_score = float(np.max(valid_scores)) + + # Score histogram + histogram = defaultdict(int) + for score in valid_scores: + bin_idx = int(score * 10) # 0.0-0.1, 0.1-0.2, ... + bin_idx = min(bin_idx, 9) # Cap at 0.9-1.0 + histogram[f"{bin_idx * 0.1:.1f}-{(bin_idx + 1) * 0.1:.1f}"] += 1 + + # Compute binary predictions at threshold + predictions = np.array([1 if s >= self.config.score_threshold else 0 + for s in valid_scores]) + + # Quality metrics + cm = confusion_matrix(valid_labels, predictions) + tn, fp, fn, tp = cm.ravel() + + accuracy = accuracy_score(valid_labels, predictions) + precision = precision_score(valid_labels, predictions, zero_division=0) + recall = recall_score(valid_labels, predictions, zero_division=0) + f1 = f1_score(valid_labels, predictions, zero_division=0) + roc_auc = roc_auc_score(valid_labels, valid_scores) + + # Latency percentiles + p50 = float(np.percentile(latencies, 50)) + p95 = float(np.percentile(latencies, 95)) + p99 = float(np.percentile(latencies, 99)) + + # Throughput + total_time = sum(latencies) / 1000 # ms to seconds + throughput = len(self.scoring_records) / total_time if total_time > 0 else 0 + + # Correlation with label + try: + correlation = float(np.corrcoef(valid_scores, valid_labels)[0, 1]) + except: + correlation = 0.0 + + # Edge cases + null_count = sum(1 for s in scores if math.isnan(s)) + short_count = sum(1 for r in self.scoring_records if r.review_length < 5) + long_count = sum(1 for r in self.scoring_records if r.review_length > 300) + poor_metadata = sum(1 for r in self.scoring_records if r.book_length < 5) + + # Pass/fail criteria + failure_reasons = [] + passes = True + + if accuracy < 0.70: + failure_reasons.append(f"Accuracy {accuracy:.2%} < 0.70") + passes = False + if precision < 0.65: + failure_reasons.append(f"Precision {precision:.2%} < 0.65") + passes = False + if recall < 0.65: + failure_reasons.append(f"Recall {recall:.2%} < 0.65") + passes = False + if p95 > 500: + failure_reasons.append(f"P95 latency {p95:.0f}ms > 500ms") + passes = False + if null_count / len(scores) > 0.01: + failure_reasons.append(f"NULL scores {null_count / len(scores):.1%} > 1%") + passes = False + + metrics = EvaluationMetrics( + mean_score=mean_score, + median_score=median_score, + std_dev=std_dev, + min_score=min_score, + max_score=max_score, + score_histogram=dict(sorted(histogram.items())), + threshold=self.config.score_threshold, + accuracy=accuracy, + precision=precision, + recall=recall, + f1=f1, + roc_auc=roc_auc, + true_positives=int(tp), + true_negatives=int(tn), + false_positives=int(fp), + false_negatives=int(fn), + p50_latency_ms=p50, + p95_latency_ms=p95, + p99_latency_ms=p99, + total_runtime_sec=total_time, + throughput_per_sec=throughput, + correlation_score_to_label=correlation, + null_score_count=null_count, + short_review_count=short_count, + long_review_count=long_count, + poor_metadata_count=poor_metadata, + passes_criteria=passes, + failure_reasons=failure_reasons, + ) + + # Log results + self._log_metrics(metrics) + return metrics + + def _log_metrics(self, metrics: EvaluationMetrics): + """Log metrics to logger""" + self.logger.info("=" * 70) + self.logger.info("EVALUATION RESULTS") + self.logger.info("=" * 70) + self.logger.info(f"Samples evaluated: {len(self.scoring_records)}") + self.logger.info(f"Threshold: {metrics.threshold}") + self.logger.info("") + self.logger.info("DISTRIBUTION:") + self.logger.info(f" Mean score: {metrics.mean_score:.3f}") + self.logger.info(f" Median score: {metrics.median_score:.3f}") + self.logger.info(f" Std dev: {metrics.std_dev:.3f}") + self.logger.info(f" Range: [{metrics.min_score:.3f}, {metrics.max_score:.3f}]") + self.logger.info("") + self.logger.info("QUALITY METRICS:") + self.logger.info(f" Accuracy: {metrics.accuracy:.1%}") + self.logger.info(f" Precision: {metrics.precision:.1%}") + self.logger.info(f" Recall: {metrics.recall:.1%}") + self.logger.info(f" F1 Score: {metrics.f1:.3f}") + self.logger.info(f" ROC-AUC: {metrics.roc_auc:.3f}") + self.logger.info("") + self.logger.info("CONFUSION MATRIX:") + self.logger.info(f" TP: {metrics.true_positives}, FP: {metrics.false_positives}") + self.logger.info(f" FN: {metrics.false_negatives}, TN: {metrics.true_negatives}") + self.logger.info("") + self.logger.info("LATENCY:") + self.logger.info(f" P50: {metrics.p50_latency_ms:.1f}ms") + self.logger.info(f" P95: {metrics.p95_latency_ms:.1f}ms") + self.logger.info(f" P99: {metrics.p99_latency_ms:.1f}ms") + self.logger.info(f" Total runtime: {metrics.total_runtime_sec:.1f}s") + self.logger.info(f" Throughput: {metrics.throughput_per_sec:.1f} samples/sec") + self.logger.info("") + self.logger.info("EDGE CASES:") + self.logger.info(f" NULL scores: {metrics.null_score_count}") + self.logger.info(f" Short reviews (<5 words): {metrics.short_review_count}") + self.logger.info(f" Long reviews (>300 words): {metrics.long_review_count}") + self.logger.info(f" Poor metadata (<5 words): {metrics.poor_metadata_count}") + self.logger.info("") + + if metrics.passes_criteria: + self.logger.info("✅ PASS: All success criteria met!") + else: + self.logger.info("❌ FAIL: Some criteria not met:") + for reason in metrics.failure_reasons: + self.logger.info(f" - {reason}") + self.logger.info("=" * 70) + + def _generate_report(self): + """Generate evaluation reports""" + # JSON report + self._write_json_report() + + # HTML report + self._write_html_report() + + # Flagged outliers CSV + self._write_outliers_csv() + + self.logger.info(f"Reports written to: {self.config.output_dir}") + + def _write_json_report(self): + """Write machine-readable JSON report""" + report = { + 'timestamp': datetime.now().isoformat(), + 'config': { + 'dataset': str(self.config.dataset_path), + 'model': self.config.model_name, + 'sample_size': self.config.sample_size, + 'threshold': self.config.score_threshold, + }, + 'metrics': asdict(self.metrics), + } + + output_file = self.config.output_dir / 'evaluation_metrics.json' + with open(output_file, 'w') as f: + json.dump(report, f, indent=2, default=str) + + self.logger.info(f"JSON report: {output_file}") + + def _write_html_report(self): + """Write interactive HTML report""" + # Build histogram HTML + histogram_html = "" + for bin_label in sorted(self.metrics.score_histogram.keys()): + count = self.metrics.score_histogram[bin_label] + width = (count / max(self.metrics.score_histogram.values())) * 200 + histogram_html += f""" + +

+ {bin_label}
+ {count} + """ + histogram_html += "" + + # Determine status color + status_color = "#4CAF50" if self.metrics.passes_criteria else "#f44336" + status_text = "✅ PASS" if self.metrics.passes_criteria else "❌ FAIL" + + # Failure reasons HTML + failure_html = "" + if self.metrics.failure_reasons: + failure_html = "
    " + for reason in self.metrics.failure_reasons: + failure_html += f"
  • {reason}
  • " + failure_html += "
" + + html_content = f""" + + + + Review Relevance Evaluation Report + + + +
+

📊 Review Relevance Scoring System - Offline Evaluation Report

+ +
{status_text}
+ +

📋 Evaluation Configuration

+ + + + + + + +
ParameterValue
Dataset{self.config.dataset_path}
Model{self.config.model_name}
Samples Evaluated{len(self.scoring_records)}
Score Threshold{self.metrics.threshold}
Timestamp{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
+ +

📊 Score Distribution

+ + + + + + + + + +
MetricValue
Mean Score{self.metrics.mean_score:.4f}
Median Score{self.metrics.median_score:.4f}
Standard Deviation{self.metrics.std_dev:.4f}
Min Score{self.metrics.min_score:.4f}
Max Score{self.metrics.max_score:.4f}
+ +

Score Histogram (0.0 - 1.0)

+ + {histogram_html} +
+ +

✅ Quality Metrics (at threshold {self.metrics.threshold})

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MetricValueStatus
Accuracy{self.metrics.accuracy:.1%} + {'✅' if self.metrics.accuracy >= 0.70 else '❌'} {'Pass' if self.metrics.accuracy >= 0.70 else 'Fail'} (target: ≥ 0.70) +
Precision{self.metrics.precision:.1%} + {'✅' if self.metrics.precision >= 0.65 else '❌'} {'Pass' if self.metrics.precision >= 0.65 else 'Fail'} (target: ≥ 0.65) +
Recall{self.metrics.recall:.1%} + {'✅' if self.metrics.recall >= 0.65 else '❌'} {'Pass' if self.metrics.recall >= 0.65 else 'Fail'} (target: ≥ 0.65) +
F1 Score{self.metrics.f1:.4f}
ROC-AUC{self.metrics.roc_auc:.4f}
+ +

🎯 Confusion Matrix (at threshold {self.metrics.threshold})

+ + + + +
Predicted RelevantPredicted Irrelevant
Actually Relevant{self.metrics.true_positives}{self.metrics.false_negatives}
Actually Irrelevant{self.metrics.false_positives}{self.metrics.true_negatives}
+ +

⚡ Performance / Latency

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MetricValueStatus
P50 Latency{self.metrics.p50_latency_ms:.1f} ms
P95 Latency{self.metrics.p95_latency_ms:.1f} ms + {'✅' if self.metrics.p95_latency_ms < 500 else '❌'} {'Pass' if self.metrics.p95_latency_ms < 500 else 'Fail'} (target: < 500ms) +
P99 Latency{self.metrics.p99_latency_ms:.1f} ms
Total Runtime{self.metrics.total_runtime_sec:.1f} sec
Throughput{self.metrics.throughput_per_sec:.1f} samples/sec
+ +

🔗 Correlation & Edge Cases

+ + + + + + + + + + + + + + + + + + + + + + + + +
MetricValue
Score-to-Label Correlation{self.metrics.correlation_score_to_label:.4f}
NULL/NaN Scores{self.metrics.null_score_count} ({self.metrics.null_score_count / len(self.scoring_records):.1%})
Short Reviews (<5 words){self.metrics.short_review_count}
Long Reviews (>300 words){self.metrics.long_review_count}
Poor Book Metadata (<5 words){self.metrics.poor_metadata_count}
+ +

📝 Summary

+ {failure_html} + +

Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

+
+ + +""" + + output_file = self.config.output_dir / 'evaluation_report.html' + with open(output_file, 'w') as f: + f.write(html_content) + + self.logger.info(f"HTML report: {output_file}") + + def _write_outliers_csv(self): + """Identify and write flagged outliers""" + outliers = [] + + for record in self.scoring_records: + is_outlier = False + reasons = [] + + # High rating but low predicted score + if record.book_embedding is not None: + label = record.ground_truth_label + score = record.relevance_score + + if label == "RELEVANT" and score < 0.4: + is_outlier = True + reasons.append(f"Low score for relevant review") + elif label == "IRRELEVANT" and score > 0.7: + is_outlier = True + reasons.append(f"High score for irrelevant review") + + if is_outlier: + outliers.append({ + 'review_id': record.review_id, + 'book_id': record.book_id, + 'predicted_score': record.relevance_score, + 'ground_truth': record.ground_truth_label, + 'confidence': record.confidence, + 'review_length': record.review_length, + 'reasons': '; '.join(reasons), + }) + + output_file = self.config.output_dir / 'flagged_outliers.csv' + if outliers: + keys = outliers[0].keys() + with open(output_file, 'w', newline='') as f: + writer = csv.DictWriter(f, fieldnames=keys) + writer.writeheader() + writer.writerows(outliers) + self.logger.info(f"Outliers CSV: {output_file} ({len(outliers)} outliers)") + else: + self.logger.info(f"No outliers found") + + +# ============================================================================ +# CLI & Main +# ============================================================================ + +def setup_logging(log_dir: Path, level_name: str = "INFO") -> logging.Logger: + """Setup logging configuration""" + log_dir.mkdir(parents=True, exist_ok=True) + + logger = logging.getLogger("OfflineEvaluation") + logger.setLevel(getattr(logging, level_name)) + + # Console handler + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(getattr(logging, level_name)) + console_formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + console_handler.setFormatter(console_formatter) + logger.addHandler(console_handler) + + # File handler + file_handler = logging.FileHandler(log_dir / 'evaluation_log.txt') + file_handler.setLevel(logging.DEBUG) + file_formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + file_handler.setFormatter(file_formatter) + logger.addHandler(file_handler) + + return logger + + +def main(): + """Main entry point""" + parser = argparse.ArgumentParser( + description='Offline evaluation of review relevance scoring system', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Full evaluation (10,000 samples): + python offline_evaluation.py --sample_size 10000 + + # Quick test (100 samples): + python offline_evaluation.py --sample_size 100 --quick_test + + # Medium test (1,000 samples): + python offline_evaluation.py --sample_size 1000 + """ + ) + + parser.add_argument( + '--dataset', + type=Path, + default=Path('phase1_datasets/ground_truth_evaluation_set.csv'), + help='Path to ground truth dataset CSV' + ) + + parser.add_argument( + '--model', + default='all-MiniLM-L6-v2', + help='Sentence transformer model name' + ) + + parser.add_argument( + '--output_dir', + type=Path, + default=Path('evaluation_results'), + help='Output directory for reports' + ) + + parser.add_argument( + '--sample_size', + type=int, + default=100, + help='Number of samples to evaluate (100, 1000, or 10000)' + ) + + parser.add_argument( + '--score_threshold', + type=float, + default=0.6, + help='Score threshold for binary classification' + ) + + parser.add_argument( + '--quick_test', + action='store_true', + help='Run quick test (100 samples)' + ) + + parser.add_argument( + '--log_level', + default='INFO', + choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'], + help='Logging level' + ) + + args = parser.parse_args() + + # Setup logging + logger = setup_logging(args.output_dir, args.log_level) + + # Create config + config = EvaluationConfig( + dataset_path=args.dataset, + model_name=args.model, + output_dir=args.output_dir, + sample_size=args.sample_size if not args.quick_test else 100, + score_threshold=args.score_threshold, + quick_test=args.quick_test, + log_level=args.log_level, + ) + + # Run evaluation + try: + engine = OfflineEvaluationEngine(config, logger) + metrics = engine.run() + + # Exit code based on pass/fail + sys.exit(0 if metrics.passes_criteria else 1) + + except Exception as e: + logger.error(f"Evaluation failed: {e}", exc_info=True) + sys.exit(2) + + +if __name__ == '__main__': + main() diff --git a/recommenderService/phase2_data_analysis.py b/recommenderService/phase2_data_analysis.py new file mode 100644 index 0000000..7e08006 --- /dev/null +++ b/recommenderService/phase2_data_analysis.py @@ -0,0 +1,544 @@ +""" +Phase 2: Book Review Relevance Scoring System +Data Analysis Script: Tasks 2.1 & 2.3 + +Analyzes real database data to design: +- Task 2.1: Book metadata aggregation strategies +- Task 2.3: Review text preprocessing pipeline +""" + +import mysql.connector +from pymongo import MongoClient +import json +import re +import statistics +from collections import defaultdict, Counter +from typing import List, Dict, Tuple, Any +import html +from datetime import datetime + +# ============================================================================ +# TASK 2.1: BOOK METADATA AGGREGATION ANALYSIS +# ============================================================================ + +class BookAggregationAnalyzer: + """Analyzes book metadata aggregation strategies on real data.""" + + def __init__(self, host='localhost', port=3307, user='root', password='070809az'): + """Initialize MySQL connection.""" + self.connection = mysql.connector.connect( + host=host, + port=port, + user=user, + password=password, + database='book_service' + ) + self.cursor = self.connection.cursor(dictionary=True) + + def query_books(self, limit=150): + """Query real books from bookService database.""" + query = """ + SELECT b.id, b.title, b.description, b.ISBN, a.name as author_name, + GROUP_CONCAT(g.name) as genres, c.name as category_name, + b.release_date, p.name as publisher_name + FROM book b + LEFT JOIN author a ON b.author_id = a.id + LEFT JOIN book_genre bg ON b.id = bg.book_id + LEFT JOIN genre g ON bg.genre_id = g.id + LEFT JOIN category c ON b.category_id = c.id + LEFT JOIN publisher p ON b.publisher_id = p.id + WHERE b.archived = 0 + GROUP BY b.id + LIMIT %s + """ + self.cursor.execute(query, (limit,)) + books = self.cursor.fetchall() + return books + + def test_aggregation_strategies(self, books: List[Dict]) -> Dict[str, Any]: + """Test three aggregation strategies on real data.""" + results = { + 'strategy_a': [], + 'strategy_b': [], + 'strategy_c': [], + 'metadata_stats': {} + } + + # Analyze metadata completeness + metadata_analysis = { + 'null_description': 0, + 'null_author': 0, + 'null_genres': 0, + 'null_category': 0, + 'null_all_metadata': 0, + 'total_books': len(books) + } + + for book in books: + # Strategy A: Simple "{title} {description}" + strategy_a_text = f"{book['title'] or ''} {book['description'] or ''}".strip() + results['strategy_a'].append({ + 'book_id': book['id'], + 'text': strategy_a_text, + 'length': len(strategy_a_text) + }) + + # Strategy B: Labeled fields + author = book['author_name'] or 'Unknown' + genres = book['genres'] or 'Untagged' + category = book['category_name'] or 'Uncategorized' + description = book['description'] or 'No description' + + strategy_b_text = f"Title: {book['title']}\nAuthor: {author}\nGenre: {genres}\nCategory: {category}\nDescription: {description}" + results['strategy_b'].append({ + 'book_id': book['id'], + 'text': strategy_b_text, + 'length': len(strategy_b_text) + }) + + # Strategy C: Selective (non-NULL fields, priority to description) + parts = [] + if book['title']: + parts.append(f"Title: {book['title']}") + if book['author_name']: + parts.append(f"Author: {book['author_name']}") + if book['genres']: + parts.append(f"Genres: {book['genres']}") + if book['category_name']: + parts.append(f"Category: {book['category_name']}") + if book['description']: + parts.append(f"Description: {book['description']}") + + strategy_c_text = "\n".join(parts) if parts else "No metadata available" + results['strategy_c'].append({ + 'book_id': book['id'], + 'text': strategy_c_text, + 'length': len(strategy_c_text) + }) + + # Metadata analysis + if not book['description']: + metadata_analysis['null_description'] += 1 + if not book['author_name']: + metadata_analysis['null_author'] += 1 + if not book['genres']: + metadata_analysis['null_genres'] += 1 + if not book['category_name']: + metadata_analysis['null_category'] += 1 + + if not (book['title'] and (book['description'] or book['genres'] or book['category_name'])): + metadata_analysis['null_all_metadata'] += 1 + + results['metadata_stats'] = metadata_analysis + return results + + def analyze_edge_cases(self, books: List[Dict]) -> Dict[str, Any]: + """Identify and measure edge cases from real data.""" + edge_cases = { + 'null_description_pct': 0, + 'description_too_short': 0, # < 20 chars + 'description_too_long': 0, # > 500 chars + 'incomplete_metadata': 0, # title only + 'null_title': 0, + 'description_lengths': [] + } + + for book in books: + if not book['description']: + edge_cases['null_description_pct'] += 1 + else: + edge_cases['description_lengths'].append(len(book['description'])) + if len(book['description']) < 20: + edge_cases['description_too_short'] += 1 + if len(book['description']) > 500: + edge_cases['description_too_long'] += 1 + + if not book['title']: + edge_cases['null_title'] += 1 + + # Incomplete: only title, no other fields + has_other_fields = bool(book['description'] or book['author_name'] or + book['genres'] or book['category_name']) + if book['title'] and not has_other_fields: + edge_cases['incomplete_metadata'] += 1 + + total = len(books) + edge_cases['null_description_pct'] = (edge_cases['null_description_pct'] / total) * 100 + edge_cases['description_too_short_pct'] = (edge_cases['description_too_short'] / total) * 100 + edge_cases['description_too_long_pct'] = (edge_cases['description_too_long'] / total) * 100 + edge_cases['incomplete_metadata_pct'] = (edge_cases['incomplete_metadata'] / total) * 100 + edge_cases['null_title_pct'] = (edge_cases['null_title'] / total) * 100 + + if edge_cases['description_lengths']: + edge_cases['avg_description_length'] = statistics.mean(edge_cases['description_lengths']) + edge_cases['median_description_length'] = statistics.median(edge_cases['description_lengths']) + edge_cases['max_description_length'] = max(edge_cases['description_lengths']) + edge_cases['min_description_length'] = min(edge_cases['description_lengths']) + + return edge_cases + + def compute_strategy_stats(self, strategy_results: List[Dict]) -> Dict[str, Any]: + """Compute statistics for aggregation strategy results.""" + lengths = [item['length'] for item in strategy_results] + + return { + 'avg_length': statistics.mean(lengths) if lengths else 0, + 'median_length': statistics.median(lengths) if lengths else 0, + 'max_length': max(lengths) if lengths else 0, + 'min_length': min(lengths) if lengths else 0, + 'total_books': len(strategy_results) + } + + def close(self): + """Close database connection.""" + self.cursor.close() + self.connection.close() + + +# ============================================================================ +# TASK 2.3: REVIEW TEXT PREPROCESSING ANALYSIS +# ============================================================================ + +class ReviewPreprocessingAnalyzer: + """Analyzes review text characteristics and preprocessing needs.""" + + def __init__(self, host='localhost', port=27018, db='review_db'): + """Initialize MongoDB connection.""" + self.client = MongoClient(f'mongodb://{host}:{port}/') + self.db = self.client[db] + self.reviews_collection = self.db['reviews'] + + def query_reviews(self, limit=500): + """Query real reviews from reviewService MongoDB.""" + reviews = list(self.reviews_collection.find().limit(limit)) + return reviews + + def analyze_text_distribution(self, reviews: List[Dict]) -> Dict[str, Any]: + """Measure review text distribution characteristics.""" + text_lengths = [] + has_html = 0 + has_emojis = 0 + empty_reviews = 0 + special_char_patterns = defaultdict(int) + + for review in reviews: + text = review.get('text', '') + + if not text or text.strip() == '': + empty_reviews += 1 + continue + + text_lengths.append(len(text)) + + # Check for HTML tags + if bool(re.search(r'<[^>]+>', text)): + has_html += 1 + + # Check for emojis (basic detection) + if bool(re.search(r'[^\x00-\x7F]', text)): # Non-ASCII + has_emojis += 1 + + # Check for special patterns + if bool(re.search(r'[!]{2,}', text)): + special_char_patterns['repeated_exclamation'] += 1 + if bool(re.search(r'[?]{2,}', text)): + special_char_patterns['repeated_question'] += 1 + if bool(re.search(r'SPOILER|spoiler', text)): + special_char_patterns['spoiler_marker'] += 1 + if bool(re.search(r'[*_~]{2,}', text)): + special_char_patterns['markdown_formatting'] += 1 + + analysis = { + 'total_reviews': len(reviews), + 'empty_reviews': empty_reviews, + 'empty_reviews_pct': (empty_reviews / len(reviews)) * 100 if reviews else 0, + 'reviews_with_text': len(text_lengths), + 'has_html': has_html, + 'has_html_pct': (has_html / len(text_lengths)) * 100 if text_lengths else 0, + 'has_emojis_or_unicode': has_emojis, + 'has_emojis_pct': (has_emojis / len(text_lengths)) * 100 if text_lengths else 0, + 'special_patterns': dict(special_char_patterns) + } + + if text_lengths: + analysis['length_min'] = min(text_lengths) + analysis['length_max'] = max(text_lengths) + analysis['length_mean'] = statistics.mean(text_lengths) + analysis['length_median'] = statistics.median(text_lengths) + analysis['length_stdev'] = statistics.stdev(text_lengths) if len(text_lengths) > 1 else 0 + analysis['length_95th_percentile'] = sorted(text_lengths)[int(len(text_lengths) * 0.95)] + + return analysis + + def estimate_token_count(self, text: str, chars_per_token: float = 4.0) -> int: + """Estimate token count using character-to-token ratio.""" + return max(1, int(len(text) / chars_per_token)) + + def test_preprocessing_steps(self, reviews: List[Dict]) -> Dict[str, Any]: + """Test impact of preprocessing steps on real reviews.""" + results = { + 'html_decode_impact': {'affected_reviews': 0, 'total_reviews': 0}, + 'lowercase_impact': {'affected_reviews': 0, 'total_reviews': 0}, + 'emoji_handling': {'found_in_reviews': 0, 'total_reviews': 0}, + 'whitespace_normalization': {'affected_reviews': 0, 'total_reviews': 0}, + 'token_distribution': defaultdict(int), + 'preprocessing_examples': [] + } + + for i, review in enumerate(reviews[:10]): # Sample for before/after + text = review.get('text', '') + if not text or text.strip() == '': + continue + + results['total_reviews'] = len(reviews) + + # HTML decode test + decoded = html.unescape(text) + if decoded != text: + results['html_decode_impact']['affected_reviews'] += 1 + results['html_decode_impact']['total_reviews'] += 1 + + # Lowercase test + lowercased = text.lower() + if lowercased != text: + results['lowercase_impact']['affected_reviews'] += 1 + results['lowercase_impact']['total_reviews'] += 1 + + # Emoji/Unicode test + if bool(re.search(r'[^\x00-\x7F]', text)): + results['emoji_handling']['found_in_reviews'] += 1 + results['emoji_handling']['total_reviews'] += 1 + + # Whitespace normalization test + normalized = ' '.join(text.split()) + if normalized != text: + results['whitespace_normalization']['affected_reviews'] += 1 + results['whitespace_normalization']['total_reviews'] += 1 + + # Token count distribution + tokens = self.estimate_token_count(text) + results['token_distribution'][f'{(tokens // 50) * 50}-{((tokens // 50) + 1) * 50 - 1}'] += 1 + + # Collect preprocessing examples (first 10) + if len(results['preprocessing_examples']) < 10: + results['preprocessing_examples'].append({ + 'review_id': str(review.get('_id', 'N/A')), + 'original': text[:100], + 'lowercased': lowercased[:100], + 'html_decoded': decoded[:100], + 'normalized': normalized[:100], + 'estimated_tokens': tokens, + 'rating': review.get('rating', 'N/A') + }) + + return results + + def close(self): + """Close MongoDB connection.""" + self.client.close() + + +# ============================================================================ +# MAIN EXECUTION +# ============================================================================ + +def print_section(title: str, width: int = 80): + """Print formatted section header.""" + print("\n" + "=" * width) + print(f" {title}") + print("=" * width) + +def print_subsection(title: str, width: int = 80): + """Print formatted subsection header.""" + print(f"\n{title}") + print("-" * width) + +def format_percentage(value: float) -> str: + """Format percentage for display.""" + return f"{value:.2f}%" + +def main(): + """Execute Phase 2 analysis.""" + print_section("PHASE 2: BOOK REVIEW RELEVANCE SCORING SYSTEM") + print("Data Analysis: Tasks 2.1 & 2.3") + print(f"Timestamp: {datetime.now().isoformat()}") + + # ======================================================================== + # TASK 2.1: BOOK METADATA AGGREGATION STRATEGY + # ======================================================================== + print_section("TASK 2.1: BOOK METADATA AGGREGATION STRATEGY ANALYSIS") + + try: + book_analyzer = BookAggregationAnalyzer() + print("\n✓ Connected to bookService MySQL database") + + # Query real books + books = book_analyzer.query_books(limit=150) + print(f"✓ Queried {len(books)} real books from database") + + if not books: + print("⚠ No books found in database. Ensure databases are populated.") + return + + # Test aggregation strategies + print_subsection("Testing Aggregation Strategies on Real Data") + strategy_results = book_analyzer.test_aggregation_strategies(books) + + # Compute statistics for each strategy + strategy_a_stats = book_analyzer.compute_strategy_stats(strategy_results['strategy_a']) + strategy_b_stats = book_analyzer.compute_strategy_stats(strategy_results['strategy_b']) + strategy_c_stats = book_analyzer.compute_strategy_stats(strategy_results['strategy_c']) + + print(f"\nStrategy A (Simple): 'Title Description'") + print(f" - Average length: {strategy_a_stats['avg_length']:.0f} chars") + print(f" - Median length: {strategy_a_stats['median_length']:.0f} chars") + print(f" - Max length: {strategy_a_stats['max_length']:.0f} chars") + print(f" - Min length: {strategy_a_stats['min_length']:.0f} chars") + + print(f"\nStrategy B (Labeled): 'Title: ... Author: ... Genre: ... Description: ...'") + print(f" - Average length: {strategy_b_stats['avg_length']:.0f} chars") + print(f" - Median length: {strategy_b_stats['median_length']:.0f} chars") + print(f" - Max length: {strategy_b_stats['max_length']:.0f} chars") + print(f" - Min length: {strategy_b_stats['min_length']:.0f} chars") + + print(f"\nStrategy C (Selective): Only non-NULL fields, description priority") + print(f" - Average length: {strategy_c_stats['avg_length']:.0f} chars") + print(f" - Median length: {strategy_c_stats['median_length']:.0f} chars") + print(f" - Max length: {strategy_c_stats['max_length']:.0f} chars") + print(f" - Min length: {strategy_c_stats['min_length']:.0f} chars") + + # Edge case analysis + print_subsection("Edge Case Analysis from Real Database") + edge_cases = book_analyzer.analyze_edge_cases(books) + + print(f"\nBooks with NULL description: {format_percentage(edge_cases['null_description_pct'])}") + print(f"Books with description < 20 chars: {format_percentage(edge_cases['description_too_short_pct'])}") + print(f"Books with description > 500 chars: {format_percentage(edge_cases['description_too_long_pct'])}") + print(f"Books with incomplete metadata (title only): {format_percentage(edge_cases['incomplete_metadata_pct'])}") + print(f"Books with NULL title: {format_percentage(edge_cases['null_title_pct'])}") + + if edge_cases['description_lengths']: + print(f"\nDescription Length Statistics (from {len(edge_cases['description_lengths'])} books):") + print(f" - Average: {edge_cases['avg_description_length']:.0f} chars") + print(f" - Median: {edge_cases['median_description_length']:.0f} chars") + print(f" - Min: {edge_cases['min_description_length']:.0f} chars") + print(f" - Max: {edge_cases['max_description_length']:.0f} chars") + + # Metadata completeness + metadata = strategy_results['metadata_stats'] + print(f"\nMetadata Completeness Analysis:") + print(f" - Books with NULL author: {format_percentage((metadata['null_author'] / metadata['total_books']) * 100)}") + print(f" - Books with NULL genres: {format_percentage((metadata['null_genres'] / metadata['total_books']) * 100)}") + print(f" - Books with NULL category: {format_percentage((metadata['null_category'] / metadata['total_books']) * 100)}") + + # Sample books for each strategy + print_subsection("Sample Aggregated Text from Real Books (Strategy Examples)") + sample_count = min(5, len(books)) + for idx in range(sample_count): + book = books[idx] + print(f"\nBook {idx + 1}: {book['title']}") + print(f" Strategy A: {strategy_results['strategy_a'][idx]['text'][:80]}...") + print(f" Strategy B: {strategy_results['strategy_b'][idx]['text'][:80]}...") + print(f" Strategy C: {strategy_results['strategy_c'][idx]['text'][:80]}...") + + book_analyzer.close() + + except Exception as e: + print(f"✗ Error in Task 2.1: {e}") + import traceback + traceback.print_exc() + + # ======================================================================== + # TASK 2.3: REVIEW TEXT PREPROCESSING ANALYSIS + # ======================================================================== + print_section("TASK 2.3: REVIEW TEXT PREPROCESSING PIPELINE ANALYSIS") + + try: + review_analyzer = ReviewPreprocessingAnalyzer() + print("\n✓ Connected to reviewService MongoDB") + + # Query real reviews + reviews = review_analyzer.query_reviews(limit=500) + print(f"✓ Queried {len(reviews)} reviews from MongoDB") + + if not reviews: + print("⚠ No reviews found in database. Ensure MongoDB is populated.") + return + + # Analyze text characteristics + print_subsection("Review Text Characteristics") + text_analysis = review_analyzer.analyze_text_distribution(reviews) + + print(f"\nText Distribution Statistics (from {text_analysis['total_reviews']} reviews):") + print(f" - Empty/blank reviews: {text_analysis['empty_reviews']} ({format_percentage(text_analysis['empty_reviews_pct'])})") + print(f" - Reviews with actual text: {text_analysis['reviews_with_text']}") + print(f" - Reviews with HTML tags: {text_analysis['has_html']} ({format_percentage(text_analysis['has_html_pct'])})") + print(f" - Reviews with emojis/unicode: {text_analysis['has_emojis_or_unicode']} ({format_percentage(text_analysis['has_emojis_pct'])})") + + print(f"\nText Length Distribution (from {text_analysis['reviews_with_text']} non-empty reviews):") + print(f" - Minimum: {text_analysis.get('length_min', 'N/A')} chars") + print(f" - Maximum: {text_analysis.get('length_max', 'N/A')} chars") + print(f" - Mean: {text_analysis.get('length_mean', 'N/A'):.0f} chars") + print(f" - Median: {text_analysis.get('length_median', 'N/A'):.0f} chars") + print(f" - 95th percentile: {text_analysis.get('length_95th_percentile', 'N/A')} chars") + + if text_analysis['special_patterns']: + print(f"\nSpecial Patterns Found:") + for pattern, count in text_analysis['special_patterns'].items(): + pct = (count / text_analysis['reviews_with_text']) * 100 + print(f" - {pattern}: {count} reviews ({format_percentage(pct)})") + + # Test preprocessing steps + print_subsection("Preprocessing Step Impact Analysis") + preprocessing = review_analyzer.test_preprocessing_steps(reviews) + + html_affected = preprocessing['html_decode_impact']['affected_reviews'] + html_total = preprocessing['html_decode_impact']['total_reviews'] + print(f"\n1. HTML Decode: {html_affected} reviews contain HTML ({format_percentage((html_affected / html_total) * 100) if html_total else 'N/A'})") + + lower_affected = preprocessing['lowercase_impact']['affected_reviews'] + lower_total = preprocessing['lowercase_impact']['total_reviews'] + print(f"2. Lowercase: {lower_affected} reviews have uppercase chars ({format_percentage((lower_affected / lower_total) * 100) if lower_total else 'N/A'})") + + emoji_affected = preprocessing['emoji_handling']['found_in_reviews'] + emoji_total = preprocessing['emoji_handling']['total_reviews'] + print(f"3. Emoji/Unicode Handling: {emoji_affected} reviews contain special chars ({format_percentage((emoji_affected / emoji_total) * 100) if emoji_total else 'N/A'})") + + ws_affected = preprocessing['whitespace_normalization']['affected_reviews'] + ws_total = preprocessing['whitespace_normalization']['total_reviews'] + print(f"4. Whitespace Normalization: {ws_affected} reviews need normalization ({format_percentage((ws_affected / ws_total) * 100) if ws_total else 'N/A'})") + + # Token limit analysis + print_subsection("Token Count Distribution & Truncation Analysis") + print(f"\nEstimated tokens per review (using 4 chars/token ratio):") + if preprocessing.get('preprocessing_examples'): + sample = preprocessing['preprocessing_examples'][0] + median_tokens = statistics.median([ex['estimated_tokens'] for ex in preprocessing['preprocessing_examples']]) + print(f" - Median tokens (from sample): {median_tokens:.0f}") + + # Sentence Transformer token limit is ~512 + reviews_exceeding_limit = sum(1 for ex in preprocessing['preprocessing_examples'] if ex['estimated_tokens'] > 512) + print(f" - Reviews exceeding 512 token limit: {reviews_exceeding_limit}/10 in sample") + + # Before/after examples + print_subsection("Before/After Preprocessing Examples (First 10 Reviews)") + for i, example in enumerate(preprocessing.get('preprocessing_examples', [])[:10], 1): + print(f"\n[Review {i}] Rating: {example['rating']}, Tokens: {example['estimated_tokens']}") + print(f" Original: {example['original'][:70]}") + print(f" Lowercased: {example['lowercased'][:70]}") + print(f" HTML Decoded: {example['html_decoded'][:70]}") + print(f" Whitespace Normalized: {example['normalized'][:70]}") + + review_analyzer.close() + + except Exception as e: + print(f"✗ Error in Task 2.3: {e}") + import traceback + traceback.print_exc() + + print_section("ANALYSIS COMPLETE") + print("\nGenerated comprehensive data analysis for Phase 2 tasks 2.1 and 2.3") + print("Review the findings above to determine aggregation strategy and preprocessing pipeline.") + +if __name__ == '__main__': + main() diff --git a/recommenderService/requirements.txt b/recommenderService/requirements.txt new file mode 100644 index 0000000..62a60df --- /dev/null +++ b/recommenderService/requirements.txt @@ -0,0 +1,16 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +numpy==1.24.3 +scipy==1.11.2 +scikit-learn==1.3.1 +joblib==1.3.2 +pydantic==2.4.2 +python-dotenv==1.0.0 +# Semantic text embeddings for review relevance scoring (Task 1.9) +# Version 5.4.1: Latest stable with compatibility for torch 2.x and transformers 4.x +# Used to generate 384-dimensional embeddings for reviews and book descriptions +# Auto-caches downloaded models in ~/.cache/huggingface/ +sentence-transformers>=3.0.0 +# Kafka event streaming and schema management +aiokafka>=0.10.0 +confluent-kafka[avro]>=2.0.0 diff --git a/recommenderService/routes/__init__.py b/recommenderService/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/recommenderService/routes/recommendations.py b/recommenderService/routes/recommendations.py new file mode 100644 index 0000000..38d0157 --- /dev/null +++ b/recommenderService/routes/recommendations.py @@ -0,0 +1,144 @@ +from typing import List, Dict, Any +from fastapi import APIRouter, HTTPException +from utils.logger import logger +from models.request_response_models import ( + SimilarBookResponse, + PersonalizedRecommendationResponse, + GetSimilarBooksRequest, + GetPersonalizedRecommendationsRequest, + HealthCheckResponse +) + +router = APIRouter(prefix="/api/v1/recommendations", tags=["recommendations"]) + +# Will be populated by main.py via dependency injection +ml_loader = None +recommender = None + + +def set_recommender_context(loader, recommender_instance): + """Set recommender context (called from main.py)""" + global ml_loader, recommender + ml_loader = loader + recommender = recommender_instance + + +@router.get("/health", response_model=HealthCheckResponse, tags=["health"]) +async def health_check(): + """Check if recommenderService is healthy and models are loaded""" + return HealthCheckResponse( + status="healthy", + modelsLoaded=ml_loader.is_ready() if ml_loader else False, + modelMetadata=ml_loader.model_metadata if ml_loader else None + ) + + +@router.post("/similar-books", response_model=List[SimilarBookResponse]) +async def get_similar_books(request: GetSimilarBooksRequest): + """ + Get similar books to a given book using content-based filtering (TF-IDF). + Real-time computation (no caching). Returns explanation of why books are similar. + + Example: + POST /api/v1/recommendations/similar-books + { + "bookId": 42, + "topK": 10 + } + + Response: + [ + { + "bookId": 15, + "similarityScore": 0.87, + "explanation": { + "primaryReason": "Content similarity: 5 matching topics", + "reasonType": "TF_IDF_MATCH", + "topContributors": ["mystery", "thriller", "detective", "london", "crime"], + "confidenceScore": 0.87 + } + }, + ... + ] + """ + try: + if not recommender: + raise HTTPException(status_code=503, detail="Recommender service not initialized") + + # Use new method with explanations + recommendations = recommender.content_based.get_similar_books_with_explanations( + request.bookId, request.topK + ) + return recommendations + except Exception as e: + logger.error(f"Error getting similar books: {e}") + raise HTTPException(status_code=500, detail="Failed to compute similar books") + + +@router.post("/personalized", response_model=List[PersonalizedRecommendationResponse]) +async def get_personalized_recommendations(request: GetPersonalizedRecommendationsRequest): + """ + Get personalized recommendations for a user based on their review history. + Uses item-item collaborative filtering (co-occurrence matrix). + Returns explanation about which seed book influenced each recommendation. + + Example: + POST /api/v1/recommendations/personalized + { + "userSeedBooks": [3, 7, 15, 22], + "topK": 10 + } + + Response: + [ + { + "bookId": 45, + "score": 12.5, + "seedBookIds": [3], + "explanation": { + "primaryReason": "Based on your review of book 3", + "reasonType": "COLLABORATIVE_FILTER", + "topContributors": ["3"], + "confidenceScore": 12.5 + } + }, + ... + ] + """ + try: + if not recommender: + raise HTTPException(status_code=503, detail="Recommender service not initialized") + + # Use new method with explanations + recommendations = recommender.collaborative.get_personalized_recommendations_with_explanations( + request.userSeedBooks, + request.topK + ) + return recommendations + except Exception as e: + logger.error(f"Error getting personalized recommendations: {e}") + raise HTTPException(status_code=500, detail="Failed to compute recommendations") + + +@router.post("/similar-books-batch") +async def get_similar_books_batch(request: Dict[str, Any]): + """ + Batch query for similar books. + Input: {"bookIds": [1, 2, 3], "topK": 10} + Output: {"1": [similar book responses], "2": [...], ...} + """ + try: + if not recommender: + raise HTTPException(status_code=503, detail="Recommender service not initialized") + + book_ids = request.get("bookIds", []) + top_k = request.get("topK", 10) + + results = {} + for bid in book_ids: + results[str(bid)] = recommender.get_similar_books(bid, top_k) + + return results + except Exception as e: + logger.error(f"Error in batch processing: {e}") + raise HTTPException(status_code=500, detail="Failed to process batch request") diff --git a/recommenderService/services/__init__.py b/recommenderService/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/recommenderService/services/collaborative_service.py b/recommenderService/services/collaborative_service.py new file mode 100644 index 0000000..663aec9 --- /dev/null +++ b/recommenderService/services/collaborative_service.py @@ -0,0 +1,185 @@ +from typing import List +import numpy as np +from utils.logger import logger +from models.request_response_models import ( + PersonalizedRecommendationResponse, ExplanationDetails, ExplanationReasonType +) +from services.ml_model_loader import MLModelLoader + + +class CollaborativeRecommender: + """ + Item-item collaborative filtering using co-occurrence matrix. + Pre-computed co-occurrence matrix: co_matrix[i][j] = count of users who reviewed both book_i and book_j + """ + + def __init__(self, ml_loader: MLModelLoader): + self.ml_loader = ml_loader + + def get_personalized_recommendations( + self, + user_seed_books: List[int], + top_k: int = 10 + ) -> List[PersonalizedRecommendationResponse]: + """ + Get recommendations based on user's review history (seed books). + Algorithm: + 1. For each seed book, find books that frequently co-occur with it + 2. Aggregate co-occurrence scores across all seed books + 3. Exclude seed books from results + 4. Return top-k by aggregated score + + Args: + user_seed_books: List of book IDs user has reviewed + top_k: Return top-k recommendations + + Returns: + List of (bookId, co_occurrence_score) sorted by score (desc) + """ + try: + if not user_seed_books: + return [] + + # Map book IDs to row indices + seed_indices = [] + for book_id in user_seed_books: + row_idx = self.ml_loader.bookid_to_row_mapping.get(str(book_id)) + if row_idx is not None: + seed_indices.append(row_idx) + + if not seed_indices: + logger.warning(f"No seed books found in model: {user_seed_books}") + return [] + + # Aggregate co-occurrence scores + cooccurrence_scores = np.zeros(self.ml_loader.item_cooccurrence_matrix.shape[0]) + + for seed_idx in seed_indices: + # Add co-occurrence values for this seed book to all other books + if isinstance(self.ml_loader.item_cooccurrence_matrix, np.ndarray): + cooccurrence_scores += self.ml_loader.item_cooccurrence_matrix[seed_idx] + + # Zero out seed books themselves + for seed_idx in seed_indices: + cooccurrence_scores[seed_idx] = -1 + + # Get top-k recommendations + top_indices = np.argsort(cooccurrence_scores)[-top_k:][::-1] + + # Map back to book IDs + row_to_bookid = {v: int(k) for k, v in self.ml_loader.bookid_to_row_mapping.items()} + + results = [] + for idx in top_indices: + if cooccurrence_scores[idx] >= 0: # Filter out -1 (seed books) + results.append(PersonalizedRecommendationResponse( + bookId=row_to_bookid[idx], + score=float(cooccurrence_scores[idx]) + )) + + logger.info(f"Generated {len(results)} personalized recommendations for user with {len(user_seed_books)} reviewed books") + return results + + except Exception as e: + logger.error(f"Error computing collaborative recommendations: {e}") + return [] + + def get_personalized_recommendations_with_explanations( + self, + user_seed_books: List[int], + top_k: int = 10 + ) -> List[PersonalizedRecommendationResponse]: + """ + Get recommendations WITH explanation about which seed book influenced each recommendation. + + Args: + user_seed_books: List of book IDs user has reviewed + top_k: Return top-k recommendations + + Returns: + List of PersonalizedRecommendationResponse with explanation and seed book tracking + """ + try: + if not user_seed_books: + return [] + + # Map book IDs to row indices + seed_indices = [] + for book_id in user_seed_books: + row_idx = self.ml_loader.bookid_to_row_mapping.get(str(book_id)) + if row_idx is not None: + seed_indices.append((book_id, row_idx)) + + if not seed_indices: + logger.warning(f"No seed books found in model: {user_seed_books}") + return [] + + row_to_bookid = {v: int(k) for k, v in self.ml_loader.bookid_to_row_mapping.items()} + cooccurrence_scores = np.zeros(self.ml_loader.item_cooccurrence_matrix.shape[0]) + + # Track which seed book contributed most to each recommendation + seed_contributions = {} # recommended_idx -> {seed_book_id: score} + + for seed_book_id, seed_idx in seed_indices: + cooc_row = self.ml_loader.item_cooccurrence_matrix[seed_idx] + + # Handle both sparse and dense arrays + if hasattr(cooc_row, 'toarray'): + cooc_values = cooc_row.toarray().flatten() + else: + cooc_values = cooc_row + + for rec_idx, score in enumerate(cooc_values): + cooccurrence_scores[rec_idx] += score + + # Track contribution from this seed book + if rec_idx not in seed_contributions: + seed_contributions[rec_idx] = {} + seed_contributions[rec_idx][seed_book_id] = float(score) + + # Zero out seed books themselves + for seed_book_id, seed_idx in seed_indices: + cooccurrence_scores[seed_idx] = -1 + + # Get top-k + top_indices = np.argsort(cooccurrence_scores)[-top_k:][::-1] + + results = [] + for idx in top_indices: + if cooccurrence_scores[idx] < 0: + continue + + # Find which seed book(s) influenced this recommendation + contrib = seed_contributions.get(idx, {}) + if not contrib: + continue + + # Get the seed book that most influenced this + most_influential_book = max(contrib.items(), key=lambda x: x[1]) + most_influential_id, influence_score = most_influential_book + + # Build explanation (will be enriched on Java side with actual title) + explanation = ExplanationDetails( + primaryReason=f"Based on your review of book {most_influential_id}", + reasonType=ExplanationReasonType.COLLABORATIVE_FILTER, + topContributors=[str(most_influential_id)], + confidenceScore=float(cooccurrence_scores[idx]), + details={ + "influencedBySeedBooks": list(contrib.keys()), + "cooccurrenceScore": float(cooccurrence_scores[idx]) + } + ) + + results.append(PersonalizedRecommendationResponse( + bookId=row_to_bookid[idx], + score=float(cooccurrence_scores[idx]), + explanation=explanation, + seedBookIds=[most_influential_id] + )) + + logger.info(f"Generated {len(results)} personalized recommendations with explanations for user with {len(user_seed_books)} reviewed books") + return results + + except Exception as e: + logger.error(f"Error computing collaborative recommendations with explanations: {e}") + return [] diff --git a/recommenderService/services/content_based_service.py b/recommenderService/services/content_based_service.py new file mode 100644 index 0000000..9d81981 --- /dev/null +++ b/recommenderService/services/content_based_service.py @@ -0,0 +1,173 @@ +from typing import List +import numpy as np +from sklearn.metrics.pairwise import cosine_similarity +from utils.logger import logger +from models.request_response_models import ( + SimilarBookResponse, ExplanationDetails, ExplanationReasonType +) +from services.ml_model_loader import MLModelLoader + + +class ContentBasedRecommender: + """Content-based recommendation engine using TF-IDF similarity""" + + def __init__(self, ml_loader: MLModelLoader): + self.ml_loader = ml_loader + + def get_similar_books( + self, + seed_book_id: int, + top_k: int = 10, + min_similarity_score: float = 0.0 + ) -> List[SimilarBookResponse]: + """ + Get similar books using TF-IDF cosine similarity. + + Args: + seed_book_id: The book to find similar books for + top_k: Return top-k results + min_similarity_score: Filter results below threshold + + Returns: + List of (bookId, similarity_score) sorted by score (desc) + """ + try: + # Get row index for seed book + row_idx = self.ml_loader.bookid_to_row_mapping.get(str(seed_book_id)) + if row_idx is None: + logger.warning(f"Book {seed_book_id} not found in model") + return [] + + # Get seed book's TF-IDF vector + seed_vector = self.ml_loader.tfidf_matrix[row_idx] + + # Compute cosine similarity with all books + # If sparse matrix, use scipy.sparse.csr_matrix.dot + similarities = cosine_similarity( + seed_vector.reshape(1, -1), + self.ml_loader.tfidf_matrix + )[0] + + # Exclude seed book itself (similarity = 1.0) + similarities[row_idx] = -1 + + # Get top-k indices + top_indices = np.argsort(similarities)[-top_k:][::-1] + + # Map indices back to book IDs + row_to_bookid = {v: int(k) for k, v in self.ml_loader.bookid_to_row_mapping.items()} + + results = [] + for idx in top_indices: + sim_score = float(similarities[idx]) + if sim_score >= min_similarity_score: + results.append(SimilarBookResponse( + bookId=row_to_bookid[idx], + similarityScore=sim_score + )) + + logger.info(f"Found {len(results)} similar books for book {seed_book_id}") + return results + + except Exception as e: + logger.error(f"Error computing content-based similarity: {e}") + return [] + + def get_similar_books_with_explanations( + self, + seed_book_id: int, + top_k: int = 10, + min_similarity_score: float = 0.0 + ) -> List[SimilarBookResponse]: + """ + Get similar books WITH explanation of why they're similar. + Uses TF-IDF to extract top contributing terms. + + Args: + seed_book_id: The book to find similar books for + top_k: Return top-k results + min_similarity_score: Filter results below threshold + + Returns: + List of SimilarBookResponse with explanation of similarity + """ + try: + # Get row index for seed book + row_idx = self.ml_loader.bookid_to_row_mapping.get(str(seed_book_id)) + if row_idx is None: + logger.warning(f"Book {seed_book_id} not found in model") + return [] + + # Get seed book's TF-IDF vector + seed_vector = self.ml_loader.tfidf_matrix[row_idx] + + # Compute cosine similarity with all books + similarities = cosine_similarity( + seed_vector.reshape(1, -1), + self.ml_loader.tfidf_matrix + )[0] + + # Exclude seed book itself + similarities[row_idx] = -1 + + # Get top-k indices + top_indices = np.argsort(similarities)[-top_k:][::-1] + + # Get feature names (TF-IDF terms) + feature_names = self.ml_loader.tfidf_vectorizer.get_feature_names_out() + + # Map indices back to book IDs + row_to_bookid = {v: int(k) for k, v in self.ml_loader.bookid_to_row_mapping.items()} + + results = [] + for idx in top_indices: + sim_score = float(similarities[idx]) + if sim_score < min_similarity_score: + continue + + # Extract top contributing terms + similar_vector = self.ml_loader.tfidf_matrix[idx] + + # Element-wise product of vectors = terms that appear in BOTH books + # This shows which topics/terms create the similarity + if hasattr(seed_vector, 'multiply'): + # Sparse matrix case + shared_terms = seed_vector.multiply(similar_vector) + shared_array = shared_terms.toarray().flatten() + else: + # Dense matrix case + shared_array = seed_vector * similar_vector + + # Get indices of top 5 contributing terms + top_term_indices = np.argsort(shared_array)[-5:][::-1] + top_terms = [ + feature_names[i] + for i in top_term_indices + if shared_array[i] > 0 + ] + + # Build explanation + num_matching_terms = len([t for t in top_terms if t]) + explanation = ExplanationDetails( + primaryReason=f"Content similarity: {num_matching_terms} matching topics", + reasonType=ExplanationReasonType.TF_IDF_MATCH, + topContributors=top_terms[:5], + confidenceScore=sim_score, + details={ + "matchingTerms": num_matching_terms, + "similarityScore": sim_score + } + ) + + results.append(SimilarBookResponse( + bookId=row_to_bookid[idx], + similarityScore=sim_score, + explanation=explanation + )) + + logger.info(f"Found {len(results)} similar books with explanations for book {seed_book_id}") + return results + + except Exception as e: + logger.error(f"Error computing content-based similarity with explanations: {e}") + return [] diff --git a/recommenderService/services/hybrid_recommender.py b/recommenderService/services/hybrid_recommender.py new file mode 100644 index 0000000..f491d01 --- /dev/null +++ b/recommenderService/services/hybrid_recommender.py @@ -0,0 +1,29 @@ +from typing import List +from models.request_response_models import SimilarBookResponse, PersonalizedRecommendationResponse +from services.content_based_service import ContentBasedRecommender +from services.collaborative_service import CollaborativeRecommender + + +class HybridRecommender: + """Hybrid recommendation orchestrator combining content-based and collaborative filtering""" + + def __init__(self, content_based: ContentBasedRecommender, collaborative: CollaborativeRecommender): + self.content_based = content_based + self.collaborative = collaborative + + def get_similar_books(self, book_id: int, top_k: int = 10) -> List[SimilarBookResponse]: + """Delegates to content-based engine""" + return self.content_based.get_similar_books(book_id, top_k) + + def get_personalized_recommendations( + self, + user_seed_books: List[int], + top_k: int = 10, + use_cache: bool = True, + cache_ttl_hours: int = 24 + ) -> List[PersonalizedRecommendationResponse]: + """ + Get personalized recommendations (delegated to collaborative). + Can be wrapped with caching logic (implemented in bookService via MongoDB). + """ + return self.collaborative.get_personalized_recommendations(user_seed_books, top_k) diff --git a/recommenderService/services/ml_model_loader.py b/recommenderService/services/ml_model_loader.py new file mode 100644 index 0000000..e6bead5 --- /dev/null +++ b/recommenderService/services/ml_model_loader.py @@ -0,0 +1,82 @@ +import os +import json +from datetime import datetime +from typing import Dict, Optional, List +import joblib +import numpy as np +from utils.logger import logger +from utils import constants + + +class MLModelLoader: + """Load and manage ML models in memory""" + + def __init__(self, models_dir: str): + self.models_dir = models_dir + self.tfidf_vectorizer = None + self.tfidf_matrix = None + self.item_cooccurrence_matrix = None + self.bookid_to_row_mapping = None + self.popular_books_list = None + + self.model_metadata = { + 'loaded_at': None, + 'version': None, + 'vectorizer_shape': None, + 'cooccurrence_shape': None + } + + def load_all_models(self) -> None: + """Load all artifacts into memory on service startup""" + try: + logger.info(f"Loading ML models from directory: {self.models_dir}") + + # Load TF-IDF vectorizer + vectorizer_path = os.path.join(self.models_dir, constants.TFIDF_VECTORIZER_FILE) + with open(vectorizer_path, "rb") as f: + self.tfidf_vectorizer = joblib.load(f) + logger.info(f"Loaded TF-IDF vectorizer with {len(self.tfidf_vectorizer.vocabulary_)} features") + + # Load TF-IDF matrix (dense or sparse) + tfidf_path = os.path.join(self.models_dir, constants.TFIDF_MATRIX_FILE) + with open(tfidf_path, "rb") as f: + self.tfidf_matrix = joblib.load(f) + logger.info(f"Loaded TF-IDF matrix with shape: {self.tfidf_matrix.shape}") + self.model_metadata['vectorizer_shape'] = self.tfidf_matrix.shape + + # Load item-item co-occurrence matrix (sparse format recommended) + cooccurrence_path = os.path.join(self.models_dir, constants.ITEM_COOCCURRENCE_FILE) + self.item_cooccurrence_matrix = np.load(cooccurrence_path, allow_pickle=True) + logger.info(f"Loaded item co-occurrence matrix with shape: {self.item_cooccurrence_matrix.shape}") + self.model_metadata['cooccurrence_shape'] = self.item_cooccurrence_matrix.shape + + # Load bookId ↔ row index mapping + bookid_path = os.path.join(self.models_dir, constants.BOOKID_TO_ROW_FILE) + with open(bookid_path, "r") as f: + self.bookid_to_row_mapping = json.load(f) + logger.info(f"Loaded book ID mapping with {len(self.bookid_to_row_mapping)} books") + + # Load popular books reference data + popular_path = os.path.join(self.models_dir, constants.POPULAR_BOOKS_FILE) + with open(popular_path, "r") as f: + self.popular_books_list = json.load(f) + logger.info(f"Loaded {len(self.popular_books_list)} popular books") + + self.model_metadata['loaded_at'] = datetime.now().isoformat() + logger.info("All ML models loaded successfully into memory") + + except FileNotFoundError as e: + logger.error(f"Model file not found: {e}") + raise + except Exception as e: + logger.error(f"Error loading models: {e}") + raise + + def is_ready(self) -> bool: + """Check if all models are loaded""" + return all([ + self.tfidf_vectorizer is not None, + self.tfidf_matrix is not None, + self.item_cooccurrence_matrix is not None, + self.bookid_to_row_mapping is not None + ]) diff --git a/recommenderService/tests/__init__.py b/recommenderService/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/recommenderService/tests/test_recommender.py b/recommenderService/tests/test_recommender.py new file mode 100644 index 0000000..372fba4 --- /dev/null +++ b/recommenderService/tests/test_recommender.py @@ -0,0 +1,40 @@ +""" +Unit tests for recommender service +""" +import pytest +from unittest.mock import Mock, patch, MagicMock +import numpy as np + +# These tests would require proper test setup with fixtures and mock data +# Placeholder for basic test structure + + +def test_ml_model_loader_initialization(): + """Test ML model loader initialization""" + # This test would load actual model files or use mocks + pass + + +def test_content_based_recommendation(): + """Test content-based recommendation calculation""" + pass + + +def test_collaborative_recommendation(): + """Test collaborative recommendation calculation""" + pass + + +def test_similar_books_endpoint(): + """Test similar books API endpoint""" + pass + + +def test_personalized_recommendations_endpoint(): + """Test personalized recommendations API endpoint""" + pass + + +def test_health_check_endpoint(): + """Test health check endpoint""" + pass diff --git a/recommenderService/utils/__init__.py b/recommenderService/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/recommenderService/utils/constants.py b/recommenderService/utils/constants.py new file mode 100644 index 0000000..af34b2f --- /dev/null +++ b/recommenderService/utils/constants.py @@ -0,0 +1,16 @@ +# Model file names +TFIDF_VECTORIZER_FILE = "tfidf_vectorizer.joblib" +TFIDF_MATRIX_FILE = "tfidf_matrix.joblib" +ITEM_COOCCURRENCE_FILE = "item_cooccurrence.npy" +BOOKID_TO_ROW_FILE = "bookid_to_row.json" +POPULAR_BOOKS_FILE = "popular_books.json" + +# Default parameters +DEFAULT_TOP_K = 10 +MAX_TOP_K = 50 +MIN_SIMILARITY_SCORE = 0.0 + +# API response constants +SERVICE_NAME = "recommenderService" +SERVICE_VERSION = "1.0.0" +SERVICE_DESCRIPTION = "Hybrid recommendation engine (content-based + collaborative filtering)" diff --git a/recommenderService/utils/logger.py b/recommenderService/utils/logger.py new file mode 100644 index 0000000..12b4741 --- /dev/null +++ b/recommenderService/utils/logger.py @@ -0,0 +1,26 @@ +import logging +import sys +from config import LOG_LEVEL + +# Create logger +logger = logging.getLogger(__name__) +logger.setLevel(getattr(logging, LOG_LEVEL)) + +# Create console handler +console_handler = logging.StreamHandler(sys.stdout) +console_handler.setLevel(getattr(logging, LOG_LEVEL)) + +# Create formatter +formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) + +# Add formatter to console handler +console_handler.setFormatter(formatter) + +# Add console handler to logger +logger.addHandler(console_handler) + +# Export logger +__all__ = ['logger'] diff --git a/reviewService/src/main/java/org/library/reviewService/controller/ReviewController.java b/reviewService/src/main/java/org/library/reviewService/controller/ReviewController.java index 547d0ed..7913d2c 100644 --- a/reviewService/src/main/java/org/library/reviewService/controller/ReviewController.java +++ b/reviewService/src/main/java/org/library/reviewService/controller/ReviewController.java @@ -1,6 +1,9 @@ package org.library.reviewService.controller; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import lombok.AllArgsConstructor; +import org.library.reviewService.dto.PageResponse; import org.library.reviewService.dto.review.ReviewRequest; import org.library.reviewService.dto.review.ReviewResponse; import org.library.reviewService.filter.model.DocumentFilterSpecificationBuilder; @@ -10,12 +13,17 @@ import org.library.reviewService.model.Review; import org.library.reviewService.service.AbstractService; import org.library.reviewService.service.ReviewService; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; @RestController @RequestMapping("/api/reviews") @AllArgsConstructor +@SecurityRequirement(name = "standardFlow") +@SecurityRequirement(name = "clientCredentialsFlow") public class ReviewController extends AbstractController { private final ReviewService reviewService; @@ -36,4 +44,37 @@ protected IMapper getMapper() { protected DocumentFilterSpecificationBuilder getSpecificationBuilder() { return specificationBuilder; } + + /** + * Get all reviews for a specific user + * Returns reviews without pagination limits (for recommendations) + */ + @GetMapping("/user") + @Operation(summary = "Get user's reviews", description = "Get all reviews created by a specific user") + public ResponseEntity> getUserReviews( + @RequestParam String userId) { + + try { + // Get all reviews for the user + List reviews = reviewService.getUserReviews(userId); + + // Convert to DTOs + List reviewResponses = reviews.stream() + .map(getMapper()::entityToResponse) + .collect(Collectors.toList()); + + // Return as PageResponse for consistency + PageResponse response = PageResponse.builder() + .size(reviewResponses.size()) + .total((long) reviewResponses.size()) + .pageNumber(0) + .items(reviewResponses) + .build(); + + return ResponseEntity.ok(response); + + } catch (Exception e) { + return ResponseEntity.internalServerError().build(); + } + } } diff --git a/reviewService/src/main/java/org/library/reviewService/service/ReviewService.java b/reviewService/src/main/java/org/library/reviewService/service/ReviewService.java index 166f981..f6b0dca 100644 --- a/reviewService/src/main/java/org/library/reviewService/service/ReviewService.java +++ b/reviewService/src/main/java/org/library/reviewService/service/ReviewService.java @@ -15,6 +15,7 @@ import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.stereotype.Service; +import java.util.List; import java.util.NoSuchElementException; import java.util.Optional; @@ -111,4 +112,13 @@ protected void afterCreate(Review entity) { protected void afterDelete(Review entity) { metricsService.removeReviewMetrics(entity.getBookId(), entity.getRating()); } + + /** + * Get all reviews created by a specific user + * Used for generating personalized recommendations + */ + public List getUserReviews(String userId) { + Query query = new Query(Criteria.where("userId").is(userId)); + return mongoOperations.find(query, Review.class); + } } diff --git a/reviewService/src/main/resources/application-dev.yml b/reviewService/src/main/resources/application-dev.yml index b93c315..a66a282 100644 --- a/reviewService/src/main/resources/application-dev.yml +++ b/reviewService/src/main/resources/application-dev.yml @@ -18,12 +18,12 @@ spring: properties: schema: registry: - url: http://localhost:8082 + url: http://localhost:8085 producer: properties: schema: registry: - url: http://localhost:8082 + url: http://localhost:8085 book-service: url: http://localhost:8080