From 6245946e7d39bd1517c1736070f89dcddadbb7e6 Mon Sep 17 00:00:00 2001 From: Maksym Diachuk Date: Fri, 24 Apr 2026 07:35:44 +0300 Subject: [PATCH 1/2] feat: implement ReviewRelevance scoring pipeline Add async semantic relevance scoring for reviews using sentence-transformers and Kafka event streaming: - Add core_scoring_strategies.py: preprocessing, edge-case handling, and sentence-transformer cosine similarity scoring (RelevanceScoringFunction) - Add Kafka consumer in recommenderService to process ReviewScoringRequest events and publish ReviewScoringResult back to reviewService - Add Avro schemas for ReviewScoringRequest and ReviewScoringResult events - Add ReviewScoringRequestProducer: publishes scoring requests on review create - Add ReviewScoringResultListener: persists scoring results into Review documents - Add BookMetadataAggregator: assembles title/author/genres/description for scoring - Add ReviewScoringResult model and embed score in Review entity - Enable @EnableAsync and migrate InitialDataGenerator to ApplicationReadyEvent - Expand BookResponse client DTO with nested Author/Category/Genre objects - Configure Kafka topics (review-scoring-requests/results/dlq) and schema registry - Add kafka-init Docker service to pre-create Kafka topics on stack startup Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 8 +- docker-compose.prod.yml | 4 +- docker-compose.yml | 35 +- recommenderService/core_scoring_strategies.py | 1224 +++++++++++++++++ .../review_scoring_request_v1.avsc | 43 + .../review_scoring_result_v1.avsc | 73 + recommenderService/services/kafka_consumer.py | 441 ++++++ .../test_sentence_transformer.py | 193 +++ .../src/main/java/event/BookDeletedEvent.java | 1 - .../java/event/BookRatingUpdatedEvent.java | 1 - .../ReviewServiceApplication.java | 2 + .../client/response/BookResponse.java | 73 + .../reviewService/config/SecurityConfig.java | 8 - .../datagen/InitialDataGenerator.java | 17 +- .../event/ReviewScoringRequest.java | 846 ++++++++++++ .../event/ReviewScoringResult.java | 1100 +++++++++++++++ .../consumer/ReviewScoringResultListener.java | 120 ++ .../ReviewScoringRequestProducer.java | 59 + .../library/reviewService/model/Review.java | 7 + .../model/ReviewScoringResult.java | 81 ++ .../reviewService/service/ReviewService.java | 26 + .../util/BookMetadataAggregator.java | 107 ++ .../src/main/resources/application-dev.yml | 20 +- .../src/main/resources/application-prod.yml | 6 + .../resources/avro/ReviewScoringRequest.avsc | 43 + .../resources/avro/ReviewScoringResult.avsc | 73 + 26 files changed, 4582 insertions(+), 29 deletions(-) create mode 100644 recommenderService/core_scoring_strategies.py create mode 100644 recommenderService/kafka_schemas/review_scoring_request_v1.avsc create mode 100644 recommenderService/kafka_schemas/review_scoring_result_v1.avsc create mode 100644 recommenderService/services/kafka_consumer.py create mode 100644 recommenderService/test_sentence_transformer.py create mode 100644 reviewService/src/main/java/org/library/reviewService/event/ReviewScoringRequest.java create mode 100644 reviewService/src/main/java/org/library/reviewService/event/ReviewScoringResult.java create mode 100644 reviewService/src/main/java/org/library/reviewService/integration/consumer/ReviewScoringResultListener.java create mode 100644 reviewService/src/main/java/org/library/reviewService/integration/producer/ReviewScoringRequestProducer.java create mode 100644 reviewService/src/main/java/org/library/reviewService/model/ReviewScoringResult.java create mode 100644 reviewService/src/main/java/org/library/reviewService/util/BookMetadataAggregator.java create mode 100644 reviewService/src/main/resources/avro/ReviewScoringRequest.avsc create mode 100644 reviewService/src/main/resources/avro/ReviewScoringResult.avsc diff --git a/.gitignore b/.gitignore index 4f0be98..2d1c5a8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,10 @@ docker .idea -.codemie \ No newline at end of file +.codemie + +.vscode + +data/ml_models/ +phase1_datasets/ +*.log diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 47ed6e2..345b346 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -66,11 +66,11 @@ 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://schema-registry:8082 + SCHEMA_REGISTRY_LISTENERS: http://0.0.0.0:8085 networks: - e-library-network diff --git a/docker-compose.yml b/docker-compose.yml index d791088..92dc6a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -108,14 +108,42 @@ 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 + kafka-init: + image: confluentinc/cp-kafka:7.5.0 + container_name: kafka-init + depends_on: + broker: + condition: service_started + environment: + KAFKA_BOOTSTRAP_SERVERS: broker:29092 + entrypoint: > + bash -c " + echo 'Waiting for Kafka broker to be ready...' + sleep 10 + echo 'Creating topics...' + kafka-topics --bootstrap-server broker:29092 --create --if-not-exists --topic review-scoring-requests --partitions 1 --replication-factor 1 && + kafka-topics --bootstrap-server broker:29092 --create --if-not-exists --topic review-scoring-results --partitions 1 --replication-factor 1 && + kafka-topics --bootstrap-server broker:29092 --create --if-not-exists --topic review-scoring-dlq --partitions 1 --replication-factor 1 && + echo 'Topics created successfully' + exit 0 + " + networks: + - e-library-network + kafka-ui: container_name: kafka-ui image: provectuslabs/kafka-ui:latest @@ -124,10 +152,11 @@ services: depends_on: - broker - schema-registry + - kafka-init 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 diff --git a/recommenderService/core_scoring_strategies.py b/recommenderService/core_scoring_strategies.py new file mode 100644 index 0000000..e2b6a4b --- /dev/null +++ b/recommenderService/core_scoring_strategies.py @@ -0,0 +1,1224 @@ +""" +Phase 2: Core Scoring Strategies Implementation +Tasks 2.1, 2.2, 2.3, 2.4 & 2.5 - Production Code + +Implements: +- Task 2.1: Book metadata aggregation (Strategy C) +- Task 2.2: Book edge case handling +- Task 2.3: Review text preprocessing pipeline (7-step) +- Task 2.4: Review edge case handling +- Task 2.5: Core relevance scoring function +""" + +import re +import html +import logging +import numpy as np +from typing import Dict, Optional, Tuple, List +from dataclasses import dataclass +from enum import Enum + +# Configure logging +logger = logging.getLogger(__name__) + + +# ============================================================================ +# TASK 2.1: BOOK METADATA AGGREGATION (Strategy C) +# ============================================================================ + +class BookMetadataAggregator: + """ + Aggregates book metadata using Strategy C (Selective Fields). + + Selected strategy prioritizes: + 1. Title (always included - should never be NULL) + 2. Author (quality metadata) + 3. Genres (important for categorization) + 4. Category (broad classification) + 5. Description (ALWAYS at end - highest semantic value) + + Skips NULL/empty fields to avoid placeholder noise. + """ + + @staticmethod + def aggregate( + title: Optional[str], + author_name: Optional[str] = None, + genres: Optional[str] = None, + category_name: Optional[str] = None, + description: Optional[str] = None + ) -> str: + """ + Aggregate book metadata using Strategy C. + + Args: + title: Book title (required) + author_name: Author name + genres: Comma-separated genres + category_name: Primary category + description: Book description (max 500 chars) + + Returns: + str: Aggregated metadata text in format optimized for embedding + + Example: + >>> agg = BookMetadataAggregator.aggregate( + ... title="The Great Gatsby", + ... author_name="F. Scott Fitzgerald", + ... genres="Fiction, Romance", + ... description="A classic American novel..." + ... ) + >>> print(agg) + Title: The Great Gatsby + Author: F. Scott Fitzgerald + Genres: Fiction, Romance + Description: A classic American novel... + """ + parts = [] + + # Always include title (should never be NULL per schema) + if title and title.strip(): + parts.append(f"Title: {title.strip()}") + + # Include author if available (quality metadata) + if author_name and author_name.strip(): + parts.append(f"Author: {author_name.strip()}") + + # Include genres if available (useful for semantic understanding) + if genres and genres.strip(): + parts.append(f"Genres: {genres.strip()}") + + # Include category if available (broad classification) + if category_name and category_name.strip(): + parts.append(f"Category: {category_name.strip()}") + + # ALWAYS include description last (highest priority for semantic content) + # Description is the most information-rich field for relevance scoring + if description and description.strip(): + parts.append(f"Description: {description.strip()}") + + # Join with newlines for readability and semantic separation + aggregated_text = "\n".join(parts) + + # Fallback for edge case (no fields populated) + if not aggregated_text: + aggregated_text = "No book metadata available" + + return aggregated_text + + @staticmethod + def aggregate_from_dict(book: Dict) -> str: + """ + Aggregate metadata from a dictionary (convenience method). + + Args: + book: Dictionary with optional keys: + 'title', 'author_name', 'genres', 'category_name', 'description' + + Returns: + str: Aggregated metadata text + """ + return BookMetadataAggregator.aggregate( + title=book.get('title'), + author_name=book.get('author_name'), + genres=book.get('genres'), + category_name=book.get('category_name'), + description=book.get('description') + ) + + +# ============================================================================ +# TASK 2.3: REVIEW TEXT PREPROCESSING (7-Step Pipeline) +# ============================================================================ + +@dataclass +class PreprocessingStats: + """Statistics about preprocessing operations.""" + original_length: int + final_length: int + tokens_before: int + tokens_after: int + was_truncated: bool + steps_applied: list + + +class ReviewTextPreprocessor: + """ + Preprocesses review text for Sentence Transformer embedding. + + 7-step pipeline: + 1. HTML entity decoding + 2. Lowercase conversion + 3. Emoji/unicode removal + 4. Whitespace normalization + 5. Spoiler marker removal + 6. Repeated punctuation collapse + 7. Token-limit truncation + + Sentence Transformer limits: + - Model: all-MiniLM-L6-v2 (~512 token hard limit) + - Approximation: 4 characters โ‰ˆ 1 token + - Truncation threshold: 1800 chars (~450 tokens) + """ + + # Regex patterns for preprocessing + SPOILER_PATTERN = re.compile( + r'\bspoiler\s*:?\s*|\bspoiler\s+alert\b', + flags=re.IGNORECASE + ) + REPEATED_PUNCT_PATTERN = re.compile(r'([!?.])\1{2,}') + SENTENCE_END_PATTERN = re.compile(r'[.!?]') + + # Constants + CHARS_PER_TOKEN = 4 # Approximation for Sentence Transformer + MAX_TOKENS = 450 # Safety margin below 512 limit + MAX_CHARS = MAX_TOKENS * CHARS_PER_TOKEN # 1800 chars + + @staticmethod + def preprocess( + text: str, + max_tokens: int = MAX_TOKENS, + compute_stats: bool = False + ) -> str: + """ + Preprocess review text through 7-step pipeline. + + Args: + text: Raw review text from database + max_tokens: Token limit for Sentence Transformer (~512) + compute_stats: Whether to compute processing statistics + + Returns: + str: Cleaned, preprocessed review text + + Example: + >>> raw = "I LOVED THIS!!! ๐Ÿ˜๐Ÿ˜๐Ÿ˜ SPOILER ALERT: The ending..." + >>> clean = ReviewTextPreprocessor.preprocess(raw) + >>> print(clean) + i loved this! the ending... + """ + if not text or not isinstance(text, str): + return "" + + # Step 1: HTML Entity Decode + # Affects 2-5% of reviews (copied from web/forums) + step1 = html.unescape(text) + + # Step 2: Lowercase Conversion + # Affects 70%+ of reviews (normal capitalization) + # Normalizes embedding space + step2 = step1.lower() + + # Step 3: Remove Emoji and Non-ASCII Unicode + # Affects 15-25% of reviews (emojis, special chars) + # Reduces token count, improves relevance focus + step3 = step2.encode('ascii', 'ignore').decode('ascii') + + # Step 4: Normalize Whitespace + # Affects 10-20% of reviews (irregular spacing/newlines) + # Improves tokenization consistency + step4 = ' '.join(step3.split()) + + # Step 5: Remove Spoiler Markers + # Affects 3-8% of reviews (spoiler warnings/tags) + # Preserves content, removes meta-markers + step5 = ReviewTextPreprocessor.SPOILER_PATTERN.sub('', step4) + step5 = step5.strip() + + # Step 6: Collapse Repeated Punctuation + # Affects 10-15% of reviews (!!!โ†’!, ???โ†’?) + # Reduces token waste + step6 = ReviewTextPreprocessor.REPEATED_PUNCT_PATTERN.sub(r'\1', step5) + + # Step 7: Truncate at Token Limit + # Affects <0.5% of reviews (only extreme outliers) + # Maintains semantic meaning via sentence boundary truncation + max_chars = max_tokens * ReviewTextPreprocessor.CHARS_PER_TOKEN + + if len(step6) > max_chars: + # Truncate at last sentence boundary + step6 = ReviewTextPreprocessor._truncate_at_sentence(step6, max_chars) + + final_text = step6.strip() + + return final_text + + @staticmethod + def preprocess_batch( + texts: list, + max_tokens: int = MAX_TOKENS + ) -> list: + """ + Preprocess multiple review texts. + + Args: + texts: List of raw review texts + max_tokens: Token limit + + Returns: + list: List of preprocessed texts + """ + return [ + ReviewTextPreprocessor.preprocess(text, max_tokens) + for text in texts + ] + + @staticmethod + def _truncate_at_sentence( + text: str, + max_chars: int + ) -> str: + """ + Truncate text at last sentence boundary. + + Preserves semantic meaning by ending at complete sentence. + + Args: + text: Text to truncate + max_chars: Maximum character length + + Returns: + str: Truncated text + """ + if len(text) <= max_chars: + return text + + # Find last sentence-ending punctuation before limit + truncated = text[:max_chars] + last_period = max( + truncated.rfind('.'), + truncated.rfind('!'), + truncated.rfind('?') + ) + + # Only use found punctuation if it's recent (within 80% of truncation point) + if last_period > max_chars * 0.8: + return truncated[:last_period + 1] + + # Fallback: truncate at last space if no recent punctuation + last_space = truncated.rfind(' ') + if last_space > max_chars * 0.9: + return truncated[:last_space] + + # Last resort: return truncated text as-is + return truncated + + @staticmethod + def estimate_tokens(text: str) -> int: + """ + Estimate token count using character approximation. + + Formula: tokens โ‰ˆ chars / 4 + + Args: + text: Text to estimate + + Returns: + int: Estimated token count + """ + return max(1, int(len(text) / ReviewTextPreprocessor.CHARS_PER_TOKEN)) + + @staticmethod + def get_preprocessing_report( + original_text: str, + preprocessed_text: str + ) -> Dict: + """ + Generate a report on preprocessing changes. + + Args: + original_text: Original review text + preprocessed_text: Preprocessed review text + + Returns: + dict: Report with statistics and changes + """ + original_tokens = ReviewTextPreprocessor.estimate_tokens(original_text) + final_tokens = ReviewTextPreprocessor.estimate_tokens(preprocessed_text) + + return { + 'original_length': len(original_text), + 'final_length': len(preprocessed_text), + 'chars_reduced': len(original_text) - len(preprocessed_text), + 'tokens_before': original_tokens, + 'tokens_after': final_tokens, + 'tokens_reduced': original_tokens - final_tokens, + 'was_truncated': len(preprocessed_text) == ReviewTextPreprocessor.MAX_CHARS, + 'length_reduction_pct': ( + (len(original_text) - len(preprocessed_text)) / len(original_text) * 100 + if len(original_text) > 0 else 0 + ), + 'within_token_limit': final_tokens <= ReviewTextPreprocessor.MAX_TOKENS, + 'has_html': '&' in original_text and ';' in original_text, + 'has_uppercase': any(c.isupper() for c in original_text), + 'has_emojis_or_unicode': len(original_text) != len( + original_text.encode('ascii', 'ignore').decode('ascii') + ), + 'has_repeated_punct': bool(re.search(r'([!?.])\1{2,}', original_text)) + } + + +# ============================================================================ +# TASK 2.2: BOOK EDGE CASE HANDLING +# ============================================================================ + +class BookEdgeCaseHandler: + """ + Handles edge cases in book metadata aggregation. + + Decision tree for edge cases: + 1. NULL/missing description (~12%): Use title only + 2. Very short description (< 20 chars, 2-5%): Include, warn + 3. Minimum threshold (< 30 chars total, 3%): Skip for scoring + 4. Very long description (> 500 chars, <1%): Truncate at DB limit + 5. Error logging: INFO/WARNING/ERROR levels + """ + + # Constants + MIN_TEXT_LENGTH = 30 # Minimum chars for valid scoring + MAX_TEXT_LENGTH = 2000 # Soft limit for safety margin + SHORT_DESC_THRESHOLD = 20 # Below this is "short" + + class EdgeCaseType(Enum): + """Types of edge cases encountered.""" + NULL_DESCRIPTION = "NULL_DESCRIPTION" + SHORT_DESCRIPTION = "SHORT_DESCRIPTION" + MINIMAL_METADATA = "MINIMAL_METADATA" + VERY_LONG_METADATA = "VERY_LONG_METADATA" + VALID = "VALID" + + @staticmethod + def validate_and_log( + book_id: int, + title: str, + description: Optional[str], + aggregated_text: str + ) -> Tuple[bool, str, 'BookEdgeCaseHandler.EdgeCaseType']: + """ + Validate aggregated book text and log edge cases. + + Args: + book_id: Book identifier + title: Book title + description: Book description (may be NULL) + aggregated_text: Already-aggregated metadata text + + Returns: + tuple: (is_valid, log_message, edge_case_type) + is_valid: True if suitable for scoring + log_message: Message for logging + edge_case_type: Classified edge case + """ + agg_len = len(aggregated_text) + + # Check: NULL/missing description + if description is None or not description.strip(): + msg = (f"Book {book_id}: description is NULL, " + f"using title only ('{title}')") + logger.info(msg) + return True, msg, BookEdgeCaseHandler.EdgeCaseType.NULL_DESCRIPTION + + # Check: Very short description + desc_len = len(description.strip()) + if desc_len < BookEdgeCaseHandler.SHORT_DESC_THRESHOLD: + msg = (f"Book {book_id}: description unusually short ({desc_len} chars), " + f"may have limited semantic context ('{description[:30]}')") + logger.warning(msg) + + # Check: Minimum aggregated text length + if agg_len < BookEdgeCaseHandler.MIN_TEXT_LENGTH: + msg = (f"Book {book_id}: minimal metadata ({agg_len} chars < {BookEdgeCaseHandler.MIN_TEXT_LENGTH}), " + f"skipping relevance scoring (text: '{aggregated_text}')") + logger.warning(msg) + return False, msg, BookEdgeCaseHandler.EdgeCaseType.MINIMAL_METADATA + + # Check: Very long metadata (rare due to DB schema) + if agg_len > BookEdgeCaseHandler.MAX_TEXT_LENGTH: + msg = (f"Book {book_id}: very long metadata ({agg_len} chars > {BookEdgeCaseHandler.MAX_TEXT_LENGTH}), " + f"truncating to preserve embedding quality") + logger.info(msg) + return True, msg, BookEdgeCaseHandler.EdgeCaseType.VERY_LONG_METADATA + + # All checks passed + return True, "Book valid for scoring", BookEdgeCaseHandler.EdgeCaseType.VALID + + +# ============================================================================ +# TASK 2.4: REVIEW EDGE CASE HANDLING +# ============================================================================ + +class ReviewEdgeCaseHandler: + """ + Handles edge cases in review text preprocessing. + + Decision tree for edge cases: + 1. NULL/empty text (5-7%): Skip for scoring + 2. Very short review (< 5 chars, 2-4%): Include, warn + 3. Gibberish/spam (> 80% non-ASCII, 1%): Skip for scoring + 4. Non-English (2-3%): Include (model is multilingual) + 5. Token limit (< 0.5%): Handled by preprocessing truncation + """ + + # Constants + MIN_REVIEW_LENGTH = 5 # Minimum chars after preprocessing + GIBBERISH_THRESHOLD = 0.80 # > 80% non-ASCII = likely spam + + class EdgeCaseType(Enum): + """Types of edge cases encountered.""" + NULL_REVIEW = "NULL_REVIEW" + SHORT_REVIEW = "SHORT_REVIEW" + GIBBERISH_SPAM = "GIBBERISH_SPAM" + EMPTY_AFTER_PREPROCESSING = "EMPTY_AFTER_PREPROCESSING" + NON_ENGLISH = "NON_ENGLISH" + TRUNCATED_REVIEW = "TRUNCATED_REVIEW" + VALID = "VALID" + + @staticmethod + def validate_and_log( + review_id: int, + original_text: str, + preprocessed_text: str + ) -> Tuple[bool, str, 'ReviewEdgeCaseHandler.EdgeCaseType']: + """ + Validate review text and log edge cases. + + Args: + review_id: Review identifier + original_text: Raw review text from DB + preprocessed_text: After 7-step preprocessing + + Returns: + tuple: (is_valid, log_message, edge_case_type) + is_valid: True if suitable for scoring + log_message: Message for logging + edge_case_type: Classified edge case + """ + + # Check: NULL or empty original text + if not original_text or not isinstance(original_text, str) or not original_text.strip(): + msg = f"Review {review_id}: empty/NULL text, skipping relevance scoring" + logger.info(msg) + return False, msg, ReviewEdgeCaseHandler.EdgeCaseType.NULL_REVIEW + + # Check: Gibberish/spam detection (optional optimization) + # Calculate non-ASCII ratio from original text + try: + ascii_version = original_text.encode('ascii', 'ignore').decode('ascii') + non_ascii_count = len(original_text) - len(ascii_version) + if len(original_text) > 0: + gibberish_ratio = non_ascii_count / len(original_text) + + if gibberish_ratio > ReviewEdgeCaseHandler.GIBBERISH_THRESHOLD: + msg = (f"Review {review_id}: gibberish/spam detected " + f"({gibberish_ratio:.1%} non-ASCII), skipping") + logger.warning(msg) + return False, msg, ReviewEdgeCaseHandler.EdgeCaseType.GIBBERISH_SPAM + except Exception as e: + logger.debug(f"Review {review_id}: could not analyze for gibberish (error: {e})") + + # Check: Empty after preprocessing + if not preprocessed_text or not preprocessed_text.strip(): + msg = (f"Review {review_id}: text became empty after preprocessing " + f"(original: {original_text[:50]}...)") + logger.warning(msg) + return False, msg, ReviewEdgeCaseHandler.EdgeCaseType.EMPTY_AFTER_PREPROCESSING + + # Check: Very short review after preprocessing + prep_len = len(preprocessed_text.strip()) + if prep_len < ReviewEdgeCaseHandler.MIN_REVIEW_LENGTH: + msg = (f"Review {review_id}: very short after preprocessing " + f"({prep_len} chars, text: '{preprocessed_text}')") + logger.warning(msg) + # Note: Still valid, just warn user + + # Check: Was truncated during preprocessing + if len(preprocessed_text) >= ReviewTextPreprocessor.MAX_CHARS: + msg = (f"Review {review_id}: was truncated during preprocessing " + f"(original > {ReviewTextPreprocessor.MAX_CHARS} chars)") + logger.info(msg) + return True, msg, ReviewEdgeCaseHandler.EdgeCaseType.TRUNCATED_REVIEW + + # All checks passed + return True, "Review valid for scoring", ReviewEdgeCaseHandler.EdgeCaseType.VALID + + +# ============================================================================ +# TASK 2.5: CORE RELEVANCE SCORING FUNCTION +# ============================================================================ + +@dataclass +class RelevanceScore: + """ + Output from relevance scoring function. + + Task 2.5: Core Relevance Scoring + + Attributes: + score: Normalized relevance score in [0.0, 1.0] + raw_cosine: Raw cosine similarity [-1, 1] (for debugging) + confidence: Confidence score [0.0, 1.0] (optional) + error_code: Error identifier if scoring failed + error_message: Human-readable error description + """ + score: Optional[float] = None + raw_cosine: Optional[float] = None + confidence: Optional[float] = None + error_code: Optional[str] = None + error_message: Optional[str] = None + + def is_valid(self) -> bool: + """Check if score was computed successfully.""" + return self.score is not None and self.error_code is None + + def __repr__(self) -> str: + """String representation.""" + if self.is_valid(): + return (f"RelevanceScore(score={self.score:.3f}, " + f"confidence={self.confidence:.3f if self.confidence else 'N/A'})") + else: + return f"RelevanceScore(error={self.error_code})" + + +class RelevanceScoringFunction: + """ + Core relevance scoring between book and review embeddings. + + Task 2.5 Implementation: + - Similarity metric: Cosine similarity [-1, 1] + - Normalization: (cos_sim + 1) / 2 โ†’ [0, 1] + - Error handling: NULL for invalid inputs + - Confidence: Computed as optional metric + + Formula: + cosine_sim = (v1 ยท v2) / (||v1|| ||v2||) + score = (cosine_sim + 1) / 2 + confidence = (|cosine_sim| * 0.7) + (embedding_quality * 0.3) + """ + + def __init__(self, model_name: str = "all-MiniLM-L6-v2"): + """ + Initialize RelevanceScoringFunction with sentence transformer model. + + Args: + model_name: Name of sentence transformer model to use + """ + try: + from sentence_transformers import SentenceTransformer + self.model = SentenceTransformer(model_name) + self.model_name = model_name + logger.info(f"Loaded sentence transformer model: {model_name}") + except Exception as e: + logger.error(f"Failed to load sentence transformer model {model_name}: {e}") + raise + + def compute_relevance_score( + self, + review_text: str, + book_metadata: Dict + ) -> RelevanceScore: + """ + Compute relevance score from raw review text and book metadata. + + Workflow: + 1. Generate embedding for review text + 2. Generate embedding for aggregated book metadata + 3. Compute cosine similarity between embeddings + 4. Normalize to [0, 1] and return with confidence + + Args: + review_text: Preprocessed review text + book_metadata: Dictionary with book metadata (from JSON) + + Returns: + RelevanceScore: Score with metadata and error info + """ + # Aggregate book metadata into text form + book_text = BookMetadataAggregator.aggregate_from_dict(book_metadata) + + try: + # Generate embeddings + review_embedding = self.model.encode(review_text, convert_to_numpy=True) + book_embedding = self.model.encode(book_text, convert_to_numpy=True) + + # Compute relevance score + return self.score_relevance(book_embedding, review_embedding, compute_confidence=True) + except Exception as e: + logger.error(f"Error computing relevance score: {e}") + return RelevanceScore( + error_code="EMBEDDING_GENERATION_ERROR", + error_message=str(e) + ) + + @staticmethod + def score_relevance( + book_embedding: Optional[np.ndarray], + review_embedding: Optional[np.ndarray], + compute_confidence: bool = True + ) -> RelevanceScore: + """ + Compute relevance score between book and review embeddings. + + Args: + book_embedding: Book metadata embedding (shape: 384,) + review_embedding: Review text embedding (shape: 384,) + compute_confidence: Whether to compute confidence score + + Returns: + RelevanceScore: Relevance score with optional debug info + + Examples: + >>> book_emb = np.random.randn(384) + >>> review_emb = np.random.randn(384) + >>> result = RelevanceScoringFunction.score_relevance(book_emb, review_emb) + >>> print(result.score) # e.g., 0.65 + >>> print(result.is_valid()) # True + """ + + # Step 1: Validate inputs + if book_embedding is None: + msg = "Cannot score: book_embedding is NULL" + logger.error(msg) + return RelevanceScore( + error_code="NULL_BOOK_EMBEDDING", + error_message=msg + ) + + if review_embedding is None: + msg = "Cannot score: review_embedding is NULL" + logger.error(msg) + return RelevanceScore( + error_code="NULL_REVIEW_EMBEDDING", + error_message=msg + ) + + # Ensure numpy arrays + try: + book_emb = np.asarray(book_embedding, dtype=np.float32) + review_emb = np.asarray(review_embedding, dtype=np.float32) + except Exception as e: + msg = f"Cannot convert embeddings to numpy: {e}" + logger.error(msg) + return RelevanceScore( + error_code="EMBEDDING_CONVERSION_ERROR", + error_message=msg + ) + + # Check for NaN values + if np.isnan(book_emb).any() or np.isnan(review_emb).any(): + msg = "Embeddings contain NaN values" + logger.error(msg) + return RelevanceScore( + error_code="NAN_VALUES", + error_message=msg + ) + + # Check for infinity + if np.isinf(book_emb).any() or np.isinf(review_emb).any(): + msg = "Embeddings contain infinity values" + logger.error(msg) + return RelevanceScore( + error_code="INF_VALUES", + error_message=msg + ) + + # Step 2: Compute norms (embeddings should be normalized, but verify) + book_norm = np.linalg.norm(book_emb) + review_norm = np.linalg.norm(review_emb) + + # Check for zero vectors + if book_norm == 0 or review_norm == 0: + msg = f"Zero vector detected (book_norm={book_norm:.6f}, review_norm={review_norm:.6f})" + logger.error(msg) + return RelevanceScore( + error_code="ZERO_VECTOR", + error_message=msg + ) + + # Step 3: Compute cosine similarity + # cos(ฮธ) = (v1 ยท v2) / (||v1|| ||v2||) + try: + dot_product = np.dot(book_emb, review_emb) + cosine_sim = dot_product / (book_norm * review_norm) + + # Clamp to [-1, 1] to handle floating point errors + cosine_sim = np.clip(cosine_sim, -1.0, 1.0) + except Exception as e: + msg = f"Error computing cosine similarity: {e}" + logger.error(msg) + return RelevanceScore( + error_code="COSINE_COMPUTATION_ERROR", + error_message=msg + ) + + # Step 4: Normalize to [0, 1] using squaring + # Squaring penalizes low scores to match semantic embedding reality. + # Cosine similarity for embeddings naturally ranges [0, 1], not [-1, 1]. + # Squaring makes discrimination clearer: 0.18ยฒ = 0.032, 0.524ยฒ = 0.275 + score = float(cosine_sim) ** 2 + + # Step 5: Compute confidence (optional) + confidence = None + if compute_confidence: + try: + # Confidence based on signal strength and embedding quality + cosine_magnitude = abs(cosine_sim) + embedding_quality = min(book_norm, review_norm) / max(book_norm, review_norm) + confidence = float((cosine_magnitude * 0.7) + (embedding_quality * 0.3)) + except Exception as e: + logger.debug(f"Could not compute confidence: {e}") + confidence = None + + # Step 6: Return result + return RelevanceScore( + score=score, + raw_cosine=float(cosine_sim), + confidence=confidence, + error_code=None, + error_message=None + ) + + @staticmethod + def score_relevance_batch( + book_embeddings: List[np.ndarray], + review_embeddings: List[np.ndarray], + compute_confidence: bool = True + ) -> List[RelevanceScore]: + """ + Score multiple book-review pairs. + + Args: + book_embeddings: List of book embeddings + review_embeddings: List of review embeddings (parallel to books) + compute_confidence: Whether to compute confidence scores + + Returns: + List[RelevanceScore]: List of relevance scores + """ + scores = [] + for book_emb, review_emb in zip(book_embeddings, review_embeddings): + score = RelevanceScoringFunction.score_relevance( + book_emb, review_emb, compute_confidence + ) + scores.append(score) + return scores + + +# ============================================================================ +# COMBINED SCORING FEATURES +# ============================================================================ + +class ReviewRelevanceScoringFeatures: + """ + Combines aggregated book metadata with preprocessed review text. + + Used as input to Sentence Transformer for creating embeddings. + """ + + @staticmethod + def create_scoring_pair( + book: Dict, + review_text: str + ) -> Tuple[str, str]: + """ + Create a book-review pair for relevance scoring. + + Args: + book: Dictionary with book metadata + review_text: Raw review text + + Returns: + tuple: (aggregated_book_text, preprocessed_review_text) + """ + aggregated_book = BookMetadataAggregator.aggregate_from_dict(book) + preprocessed_review = ReviewTextPreprocessor.preprocess(review_text) + + return aggregated_book, preprocessed_review + + @staticmethod + def create_scoring_pairs_batch( + books: list, + reviews: list + ) -> list: + """ + Create multiple book-review pairs. + + Args: + books: List of book dictionaries + reviews: List of review texts (parallel to books) + + Returns: + list: List of (aggregated_book, preprocessed_review) tuples + """ + pairs = [] + for book, review_text in zip(books, reviews): + pair = ReviewRelevanceScoringFeatures.create_scoring_pair(book, review_text) + pairs.append(pair) + return pairs + + +# ============================================================================ +# EXAMPLES AND TESTS +# ============================================================================ + +def example_task_2_1(): + """Demonstrate Task 2.1 - Book Metadata Aggregation.""" + print("=" * 80) + print("TASK 2.1: BOOK METADATA AGGREGATION (Strategy C)") + print("=" * 80) + + # Example 1: Complete metadata + book1 = { + 'title': 'The Great Gatsby', + 'author_name': 'F. Scott Fitzgerald', + 'genres': 'Fiction, Romance, Classics', + 'category_name': 'Classics', + 'description': 'A classic novel exploring the American Dream in 1920s New York, ' \ + 'following the mysterious Jay Gatsby and his obsession with Daisy Buchanan.' + } + + agg1 = BookMetadataAggregator.aggregate_from_dict(book1) + print("\nExample 1: Complete Metadata") + print("-" * 80) + print(agg1) + print(f"\nLength: {len(agg1)} chars, ~{ReviewTextPreprocessor.estimate_tokens(agg1)} tokens") + + # Example 2: Missing description + book2 = { + 'title': 'Python Programming', + 'author_name': 'Guido van Rossum', + 'genres': 'Programming, Technical', + 'category_name': 'Education', + 'description': None + } + + agg2 = BookMetadataAggregator.aggregate_from_dict(book2) + print("\nExample 2: Missing Description") + print("-" * 80) + print(agg2) + print(f"\nLength: {len(agg2)} chars, ~{ReviewTextPreprocessor.estimate_tokens(agg2)} tokens") + + # Example 3: Minimal metadata (title only) + book3 = { + 'title': '1984', + 'author_name': None, + 'genres': None, + 'category_name': None, + 'description': None + } + + agg3 = BookMetadataAggregator.aggregate_from_dict(book3) + print("\nExample 3: Minimal Metadata (Title Only)") + print("-" * 80) + print(agg3) + print(f"\nLength: {len(agg3)} chars, ~{ReviewTextPreprocessor.estimate_tokens(agg3)} tokens") + + +def example_task_2_3(): + """Demonstrate Task 2.3 - Review Text Preprocessing.""" + print("\n" + "=" * 80) + print("TASK 2.3: REVIEW TEXT PREPROCESSING (7-Step Pipeline)") + print("=" * 80) + + reviews = [ + "I LOVED THIS BOOK!!! ๐Ÿ˜๐Ÿ˜๐Ÿ˜ It was AMAZING!!!", + "TERRIBLE!!! WASTE OF MONEY!!! DO NOT BUY!!!", + "SPOILER ALERT: The villain dies at the end. But still a great read!", + "5 & 1/2 stars! "Worth every penny" as my friend said.", + "The book was good but the ending...... was disappointing???", + ] + + print("\nBefore/After Examples:") + print("-" * 80) + + for i, review in enumerate(reviews, 1): + preprocessed = ReviewTextPreprocessor.preprocess(review) + stats = ReviewTextPreprocessor.get_preprocessing_report(review, preprocessed) + + # Safe encoding: replace non-ASCII with '?' + review_safe = review.encode('ascii', 'replace').decode('ascii') + preprocessed_safe = preprocessed.encode('ascii', 'replace').decode('ascii') + + print(f"\nReview {i}:") + print(f"Original ({len(review)} chars, ~{stats['tokens_before']} tokens):") + print(f" {review_safe[:80]}{'...' if len(review) > 80 else ''}") + print(f"\nProcessed ({len(preprocessed)} chars, ~{stats['tokens_after']} tokens):") + print(f" {preprocessed_safe[:80]}{'...' if len(preprocessed) > 80 else ''}") + print(f"\nReduction: {stats['chars_reduced']} chars, {stats['tokens_reduced']} tokens") + print(f"Within limit: {stats['within_token_limit']}") + + +def example_task_2_2(): + """Demonstrate Task 2.2 - Book Edge Case Handling.""" + print("\n" + "=" * 80) + print("TASK 2.2: BOOK EDGE CASE HANDLING") + print("=" * 80) + + # Example 1: Complete metadata (valid) + print("\nExample 1: Complete Metadata") + print("-" * 80) + book1 = { + 'title': 'The Great Gatsby', + 'author_name': 'F. Scott Fitzgerald', + 'genres': 'Fiction, Romance, Classics', + 'category_name': 'Classics', + 'description': 'A classic novel exploring the American Dream.' + } + agg1 = BookMetadataAggregator.aggregate_from_dict(book1) + is_valid, msg, case = BookEdgeCaseHandler.validate_and_log(1, book1['title'], book1['description'], agg1) + print(f"Aggregated text: {agg1}") + print(f"Valid for scoring: {is_valid} (Case: {case.value})") + print(f"Message: {msg}") + + # Example 2: Missing description (edge case - NULL) + print("\nExample 2: Missing Description (~12% of books)") + print("-" * 80) + book2 = { + 'title': 'Python Programming', + 'author_name': 'Guido van Rossum', + 'genres': 'Programming, Technical', + 'category_name': 'Education', + 'description': None + } + agg2 = BookMetadataAggregator.aggregate_from_dict(book2) + is_valid, msg, case = BookEdgeCaseHandler.validate_and_log(2, book2['title'], book2['description'], agg2) + print(f"Aggregated text: {agg2}") + print(f"Valid for scoring: {is_valid} (Case: {case.value})") + print(f"Message: {msg}") + + # Example 3: Short description (edge case - SHORT) + print("\nExample 3: Short Description (<20 chars, 2-5% of books)") + print("-" * 80) + book3 = { + 'title': 'The Book', + 'author_name': None, + 'genres': None, + 'category_name': None, + 'description': 'Good' + } + agg3 = BookMetadataAggregator.aggregate_from_dict(book3) + is_valid, msg, case = BookEdgeCaseHandler.validate_and_log(3, book3['title'], book3['description'], agg3) + print(f"Aggregated text: {agg3}") + print(f"Valid for scoring: {is_valid} (Case: {case.value})") + print(f"Message: {msg}") + + # Example 4: Minimal metadata (edge case - MINIMAL) + print("\nExample 4: Minimal Metadata (<30 chars, ~3% of books)") + print("-" * 80) + book4 = { + 'title': 'Nice', + 'author_name': None, + 'genres': None, + 'category_name': None, + 'description': None + } + agg4 = BookMetadataAggregator.aggregate_from_dict(book4) + is_valid, msg, case = BookEdgeCaseHandler.validate_and_log(4, book4['title'], book4['description'], agg4) + print(f"Aggregated text: {agg4}") + print(f"Valid for scoring: {is_valid} (Case: {case.value})") + print(f"Message: {msg}") + + +def example_task_2_4(): + """Demonstrate Task 2.4 - Review Edge Case Handling.""" + print("\n" + "=" * 80) + print("TASK 2.4: REVIEW EDGE CASE HANDLING") + print("=" * 80) + + # Example 1: Valid review + print("\nExample 1: Valid Review (93% of reviews)") + print("-" * 80) + review1 = "This book was absolutely amazing! I loved every single page and couldn't put it down." + preprocessed1 = ReviewTextPreprocessor.preprocess(review1) + is_valid, msg, case = ReviewEdgeCaseHandler.validate_and_log(1, review1, preprocessed1) + print(f"Original: {review1}") + print(f"Preprocessed: {preprocessed1}") + print(f"Valid for scoring: {is_valid} (Case: {case.value})") + print(f"Message: {msg}") + + # Example 2: NULL/Empty review + print("\nExample 2: Empty/NULL Review (5-7% of reviews)") + print("-" * 80) + review2 = "" + preprocessed2 = ReviewTextPreprocessor.preprocess(review2) + is_valid, msg, case = ReviewEdgeCaseHandler.validate_and_log(2, review2, preprocessed2) + print(f"Original: '{review2}'") + print(f"Preprocessed: '{preprocessed2}'") + print(f"Valid for scoring: {is_valid} (Case: {case.value})") + print(f"Message: {msg}") + + # Example 3: Very short review + print("\nExample 3: Short Review (<5 chars, 2-4% of reviews)") + print("-" * 80) + review3 = "Good" + preprocessed3 = ReviewTextPreprocessor.preprocess(review3) + is_valid, msg, case = ReviewEdgeCaseHandler.validate_and_log(3, review3, preprocessed3) + print(f"Original: {review3}") + print(f"Preprocessed: {preprocessed3}") + print(f"Valid for scoring: {is_valid} (Case: {case.value})") + print(f"Message: {msg}") + + # Example 4: Gibberish/Spam + print("\nExample 4: Gibberish/Spam Detection (~1% of reviews)") + print("-" * 80) + review4 = "test spam signal test" # Using safe text instead of emojis + preprocessed4 = ReviewTextPreprocessor.preprocess(review4) + is_valid, msg, case = ReviewEdgeCaseHandler.validate_and_log(4, review4, preprocessed4) + print(f"Original: {review4}") + print(f"Preprocessed: '{preprocessed4}'") + print(f"Valid for scoring: {is_valid} (Case: {case.value})") + print(f"Message: {msg}") + + +def example_task_2_5(): + """Demonstrate Task 2.5 - Core Relevance Scoring.""" + print("\n" + "=" * 80) + print("TASK 2.5: CORE RELEVANCE SCORING FUNCTION") + print("=" * 80) + + np.random.seed(42) + + # Example 1: Perfect semantic match + print("\nExample 1: Perfect Semantic Match") + print("-" * 80) + book_emb1 = np.random.randn(384) + book_emb1 = book_emb1 / np.linalg.norm(book_emb1) + # Similar to book embedding + review_emb1 = book_emb1 + np.random.randn(384) * 0.05 # Small noise + review_emb1 = review_emb1 / np.linalg.norm(review_emb1) + + score1 = RelevanceScoringFunction.score_relevance(book_emb1, review_emb1) + print(f"Score: {score1.score:.4f}") + print(f"Raw Cosine: {score1.raw_cosine:.4f}") + print(f"Confidence: {score1.confidence:.4f}") + print(f"Interpretation: High relevance, clear semantic match") + + # Example 2: Unrelated texts + print("\nExample 2: Completely Unrelated Texts") + print("-" * 80) + book_emb2 = np.random.randn(384) + book_emb2 = book_emb2 / np.linalg.norm(book_emb2) + review_emb2 = np.random.randn(384) # Completely different + review_emb2 = review_emb2 / np.linalg.norm(review_emb2) + + score2 = RelevanceScoringFunction.score_relevance(book_emb2, review_emb2) + print(f"Score: {score2.score:.4f}") + print(f"Raw Cosine: {score2.raw_cosine:.4f}") + print(f"Confidence: {score2.confidence:.4f}") + print(f"Interpretation: Low relevance, unrelated content") + + # Example 3: Partial relevance + print("\nExample 3: Partial Relevance (Some Overlap)") + print("-" * 80) + book_emb3 = np.random.randn(384) + book_emb3 = book_emb3 / np.linalg.norm(book_emb3) + # Moderate noise (50% similar, 50% different) + review_emb3 = book_emb3 * 0.5 + np.random.randn(384) * 0.5 + review_emb3 = review_emb3 / np.linalg.norm(review_emb3) + + score3 = RelevanceScoringFunction.score_relevance(book_emb3, review_emb3) + print(f"Score: {score3.score:.4f}") + print(f"Raw Cosine: {score3.raw_cosine:.4f}") + print(f"Confidence: {score3.confidence:.4f}") + print(f"Interpretation: Moderate relevance, partial semantic overlap") + + # Example 4: Error handling - NULL embedding + print("\nExample 4: Error Handling - NULL Embedding") + print("-" * 80) + score4 = RelevanceScoringFunction.score_relevance(None, review_emb1) + print(f"Score: {score4.score}") + print(f"Error Code: {score4.error_code}") + print(f"Error Message: {score4.error_message}") + print(f"Is Valid: {score4.is_valid()}") + print(f"Interpretation: Cannot score due to NULL input") + + # Example 5: Batch scoring + print("\nExample 5: Batch Scoring (Multiple Pairs)") + print("-" * 80) + book_embs = [ + np.random.randn(384) / np.linalg.norm(np.random.randn(384)) for _ in range(3) + ] + review_embs = [ + np.random.randn(384) / np.linalg.norm(np.random.randn(384)) for _ in range(3) + ] + + scores = RelevanceScoringFunction.score_relevance_batch(book_embs, review_embs) + print(f"Scored {len(scores)} book-review pairs:") + for i, score in enumerate(scores, 1): + print(f" Pair {i}: score={score.score:.4f}, confidence={score.confidence:.4f}") + + +if __name__ == '__main__': + example_task_2_1() + example_task_2_3() + example_task_2_2() + example_task_2_4() + example_task_2_5() + + print("\n" + "=" * 80) + print("INTEGRATION EXAMPLE: COMPLETE SCORING PIPELINE") + print("=" * 80) + + # Create a complete scoring pair + book = { + 'title': 'To Kill a Mockingbird', + 'author_name': 'Harper Lee', + 'genres': 'Fiction, Classics', + 'category_name': 'Literature', + 'description': 'A gripping tale of racial injustice and childhood innocence in the American South.' + } + + review = "I LOVED THIS BOOK!!! ๐Ÿ˜ The characters were so well written and relatable. " \ + "Harper Lee really captured the essence of growing up in 1930s Alabama. Highly recommended!!!" + + aggregated_book, preprocessed_review = ReviewRelevanceScoringFeatures.create_scoring_pair( + book, review + ) + + # Validate edge cases + is_valid_book, msg_book, case_book = BookEdgeCaseHandler.validate_and_log( + book_id=1, + title=book['title'], + description=book['description'], + aggregated_text=aggregated_book + ) + + is_valid_review, msg_review, case_review = ReviewEdgeCaseHandler.validate_and_log( + review_id=100, + original_text=review, + preprocessed_text=preprocessed_review + ) + + print("\nBook Metadata (Aggregated):") + print(aggregated_book) + print(f"Length: {len(aggregated_book)} chars, Valid: {is_valid_book}, Case: {case_book.value}") + + print("\n" + "-" * 80) + print("\nReview Text (Preprocessed):") + print(preprocessed_review) + print(f"Length: {len(preprocessed_review)} chars, Valid: {is_valid_review}, Case: {case_review.value}") + + print("\n" + "-" * 80) + print(f"\nBoth texts ready for Sentence Transformer embedding!") + print(f"Book tokens: ~{ReviewTextPreprocessor.estimate_tokens(aggregated_book)}") + print(f"Review tokens: ~{ReviewTextPreprocessor.estimate_tokens(preprocessed_review)}") + print(f"Total tokens estimate: " + f"~{ReviewTextPreprocessor.estimate_tokens(aggregated_book) + ReviewTextPreprocessor.estimate_tokens(preprocessed_review)}") + + print("\n" + "-" * 80) + print("\nSimulating embeddings and scoring:") + # Simulate embeddings (normally from Sentence Transformer model) + np.random.seed(42) + book_emb = np.random.randn(384) + book_emb = book_emb / np.linalg.norm(book_emb) # Normalize + + review_emb = np.random.randn(384) + review_emb = review_emb / np.linalg.norm(review_emb) # Normalize + + score_result = RelevanceScoringFunction.score_relevance(book_emb, review_emb) + + print(f"\nRelevance Score Result:") + print(f" Score: {score_result.score:.4f} (normalized [0, 1])") + print(f" Raw Cosine: {score_result.raw_cosine:.4f} ([-1, 1])") + print(f" Confidence: {score_result.confidence:.4f}") + print(f" Is Valid: {score_result.is_valid()}") + print(f" Error Code: {score_result.error_code}") diff --git a/recommenderService/kafka_schemas/review_scoring_request_v1.avsc b/recommenderService/kafka_schemas/review_scoring_request_v1.avsc new file mode 100644 index 0000000..874c9e9 --- /dev/null +++ b/recommenderService/kafka_schemas/review_scoring_request_v1.avsc @@ -0,0 +1,43 @@ +{ + "type": "record", + "name": "ReviewScoringRequest", + "namespace": "org.library.reviewService.event", + "doc": "Request to score a book review for relevance using semantic similarity", + "fields": [ + { + "name": "reviewId", + "type": "string", + "doc": "Unique identifier of the review to be scored" + }, + { + "name": "bookId", + "type": "int", + "doc": "Unique identifier of the book being reviewed" + }, + { + "name": "reviewText", + "type": "string", + "doc": "The raw review text to be scored" + }, + { + "name": "bookMetadata", + "type": "string", + "doc": "Aggregated book metadata as JSON string (title, author, category, genres, description, ISBN) for semantic context" + }, + { + "name": "userId", + "type": "string", + "doc": "Unique identifier of the user who wrote the review" + }, + { + "name": "timestamp", + "type": "long", + "doc": "Unix timestamp (milliseconds) when review was created" + }, + { + "name": "correlationId", + "type": "string", + "doc": "Correlation ID for distributed tracing across services" + } + ] +} diff --git a/recommenderService/kafka_schemas/review_scoring_result_v1.avsc b/recommenderService/kafka_schemas/review_scoring_result_v1.avsc new file mode 100644 index 0000000..8c8231d --- /dev/null +++ b/recommenderService/kafka_schemas/review_scoring_result_v1.avsc @@ -0,0 +1,73 @@ +{ + "type": "record", + "name": "ReviewScoringResult", + "namespace": "org.library.reviewService.event", + "doc": "Result of semantic relevance scoring for a book review", + "fields": [ + { + "name": "reviewId", + "type": "string", + "doc": "Unique identifier of the review that was scored" + }, + { + "name": "score", + "type": [ + "null", + "double" + ], + "default": null, + "doc": "Normalized relevance score [0.0, 1.0], null if scoring failed" + }, + { + "name": "modelVersion", + "type": "string", + "doc": "Version of the semantic model used (e.g., 'all-MiniLM-L6-v2@3.0.0')" + }, + { + "name": "rawCosineSimilarity", + "type": [ + "null", + "double" + ], + "default": null, + "doc": "Raw cosine similarity [-1.0, 1.0] before normalization (debug info)" + }, + { + "name": "confidence", + "type": [ + "null", + "double" + ], + "default": null, + "doc": "Confidence score [0.0, 1.0] based on text quality and model certainty" + }, + { + "name": "errorCode", + "type": [ + "null", + "string" + ], + "default": null, + "doc": "Error code if scoring failed (e.g., 'EMPTY_REVIEW', 'GIBBERISH_TEXT')" + }, + { + "name": "errorMessage", + "type": [ + "null", + "string" + ], + "default": null, + "doc": "Descriptive error message if scoring failed" + }, + { + "name": "timestamp", + "type": "long", + "doc": "Unix timestamp (milliseconds) when scoring completed" + }, + { + "name": "processingTimeMs", + "type": "long", + "doc": "Time taken to score the review in milliseconds" + } + ] +} diff --git a/recommenderService/services/kafka_consumer.py b/recommenderService/services/kafka_consumer.py new file mode 100644 index 0000000..001c538 --- /dev/null +++ b/recommenderService/services/kafka_consumer.py @@ -0,0 +1,441 @@ +""" +Kafka consumer service for processing review scoring requests. + +Listens to review-scoring-requests topic, scores reviews using semantic similarity, +and publishes results to review-scoring-results topic via Kafka producer. +""" + +import asyncio +import json +import logging +import os +from typing import Optional +from confluent_kafka import DeserializingConsumer, SerializingProducer, Producer +from confluent_kafka.schema_registry import SchemaRegistryClient +from confluent_kafka.serialization import StringDeserializer, StringSerializer +from confluent_kafka.schema_registry.avro import AvroDeserializer, AvroSerializer +import config +from core_scoring_strategies import ( + BookMetadataAggregator, + ReviewTextPreprocessor, + BookEdgeCaseHandler, + ReviewEdgeCaseHandler, + RelevanceScoringFunction, +) +from utils.logger import logger + + +class ReviewScoringConsumer: + """ + Kafka consumer for asynchronous review scoring requests. + + Uses modern DeserializingConsumer with: + - StringDeserializer for keys (no Avro magic byte expected) + - AvroDeserializer for values (expects Avro magic byte) + + Workflow: + 1. Listen for ReviewScoringRequest events on review-scoring-requests topic + 2. Preprocess review text and book metadata + 3. Generate semantic embeddings for both + 4. Compute cosine similarity and normalize to [0, 1] + 5. Publish ReviewScoringResult to review-scoring-results topic + 6. On error: publish to dead-letter queue topic + """ + + def __init__(self, sentence_transformer_model=None): + """ + Initialize the consumer with scoring strategy components. + + Args: + sentence_transformer_model: Optional pre-loaded model instance + (for testing/initialization) + """ + self.logger = logger + self.consumer = None + self.producer = None + self.schema_registry_client = None + + # Initialize scoring strategy components + self.book_aggregator = BookMetadataAggregator() + self.review_preprocessor = ReviewTextPreprocessor() + self.book_edge_case_handler = BookEdgeCaseHandler() + self.review_edge_case_handler = ReviewEdgeCaseHandler() + self.scoring_function = RelevanceScoringFunction( + model_name=sentence_transformer_model or config.SENTENCE_TRANSFORMER_MODEL + ) + + self.logger.info(f"ReviewScoringConsumer initialized with model: {config.SENTENCE_TRANSFORMER_MODEL}") + + async def start(self): + """Start the Kafka consumer and producer.""" + try: + # Initialize schema registry client for schema retrieval by ID + self.schema_registry_client = SchemaRegistryClient({ + 'url': config.KAFKA_SCHEMA_REGISTRY_URL + }) + + # Create Avro deserializer for values (messages have Avro magic byte) + avro_deserializer = AvroDeserializer( + schema_registry_client=self.schema_registry_client + ) + + # Create StringDeserializer for keys (plain string, no magic byte) + string_deserializer = StringDeserializer('utf_8') + + # Create modern DeserializingConsumer + # Key: StringDeserializer (key is just review ID as string) + # Value: AvroDeserializer (message body is Avro-encoded) + self.consumer = DeserializingConsumer({ + 'bootstrap.servers': ','.join(config.KAFKA_BOOTSTRAP_SERVERS), + 'group.id': config.KAFKA_CONSUMER_GROUP, + 'auto.offset.reset': 'latest', + 'enable.auto.commit': True, + 'max.poll.interval.ms': 300000, # 5 minutes for processing + 'isolation.level': 'read_committed', + 'key.deserializer': string_deserializer, + 'value.deserializer': avro_deserializer, + }) + + # Create modern SerializingProducer for results + string_serializer = StringSerializer('utf_8') + + # Load ReviewScoringResult schema for value serialization + schema_path = os.path.join(os.path.dirname(__file__), '..', 'kafka_schemas', 'review_scoring_result_v1.avsc') + with open(schema_path, 'r') as f: + result_schema_dict = json.load(f) + + # Create Avro serializer with the ReviewScoringResult schema + avro_serializer = AvroSerializer( + schema_registry_client=self.schema_registry_client, + schema_str=json.dumps(result_schema_dict) + ) + + self.producer = SerializingProducer({ + 'bootstrap.servers': ','.join(config.KAFKA_BOOTSTRAP_SERVERS), + 'key.serializer': string_serializer, + 'value.serializer': avro_serializer, + }) + + # Subscribe to request topic + self.consumer.subscribe([config.KAFKA_REVIEW_SCORING_REQUEST_TOPIC]) + + self.logger.info(f"ReviewScoringConsumer started. Listening to topic: {config.KAFKA_REVIEW_SCORING_REQUEST_TOPIC}") + + # Start message processing loop + await self._process_messages() + + except Exception as e: + self.logger.error(f"Failed to start ReviewScoringConsumer: {e}", exc_info=True) + raise + """ + Subscribe to topic with retry logic. + Handles the case where topic doesn't exist yet (will be created when first message is published). + + Args: + topic: Topic name to subscribe to + max_retries: Maximum number of retry attempts (30 * 1s = 30 seconds timeout) + retry_delay: Delay between retries in seconds + """ + for attempt in range(max_retries): + try: + self.consumer.subscribe([topic]) + self.logger.info(f"Successfully subscribed to topic: {topic}") + return + except Exception as e: + error_str = str(e) + # Check if it's the expected "topic doesn't exist yet" error + if "UNKNOWN_TOPIC_OR_PART" in error_str or "not available" in error_str: + self.logger.warning( + f"Topic '{topic}' not yet available (attempt {attempt + 1}/{max_retries}). " + f"It will be created when the first message is published. Retrying in {retry_delay}s..." + ) + await asyncio.sleep(retry_delay) + continue + else: + # Different error - fail fast + self.logger.error(f"Failed to subscribe to topic '{topic}': {e}") + raise + + raise Exception(f"Failed to subscribe to topic '{topic}' after {max_retries} retries") + + async def _process_messages(self): + """Main message processing loop.""" + try: + while True: + try: + # Run blocking poll in thread pool to avoid blocking event loop + # timeout=1 second to keep loop responsive + msg = await asyncio.get_event_loop().run_in_executor( + None, self.consumer.poll, 1 + ) + + if msg is None: + await asyncio.sleep(0.1) + continue + + if msg.error(): + if msg.error().code() != -191: # -191 = partition EOF (expected) + self.logger.error(f"Kafka consumer error: {msg.error()}") + continue + + await self._handle_scoring_request(msg.value()) + + except Exception as e: + self.logger.error(f"Error processing message: {e}", exc_info=True) + # Wait before retrying to avoid tight loop on persistent errors + await asyncio.sleep(1) + + except KeyboardInterrupt: + self.logger.info("ReviewScoringConsumer shutting down...") + finally: + # Run blocking close operations in thread pool + try: + await asyncio.get_event_loop().run_in_executor(None, self._cleanup) + except Exception as e: + self.logger.error(f"Error during cleanup: {e}", exc_info=True) + + def _cleanup(self): + """Synchronous cleanup method to be run in thread pool.""" + if self.consumer: + self.consumer.close() + if self.producer: + self.producer.flush() + + async def _handle_scoring_request(self, request): + """ + Process a single ReviewScoringRequest. + + Args: + request: Deserialized ReviewScoringRequest Avro message + """ + review_id = request.get('reviewId') + book_id = request.get('bookId') + correlation_id = request.get('correlationId') + + try: + self.logger.info(f"Processing scoring request for review={review_id} " + f"book={book_id} correlation={correlation_id}") + + # Extract and preprocess review text + review_text = request.get('reviewText', '') + preprocessed_review = self.review_preprocessor.preprocess(review_text) + + # Check for review edge cases + is_valid_review, msg_review, case_review = self.review_edge_case_handler.validate_and_log( + review_id, review_text, preprocessed_review + ) + if not is_valid_review: + self.logger.warning(f"Review validation failed: {msg_review}") + await self._publish_error_result( + review_id, case_review.value, msg_review, correlation_id + ) + return + + # Extract and handle book metadata + book_metadata_json = request.get('bookMetadata', '{}') + try: + book_metadata = json.loads(book_metadata_json) + except json.JSONDecodeError: + book_metadata = {} + + # Get aggregated book metadata string for edge case validation + aggregated_metadata = self.book_aggregator.aggregate_from_dict(book_metadata) + + # Extract title from metadata for validation (provide reasonable default) + book_title = book_metadata.get('title', 'Unknown Book') + book_description = book_metadata.get('description') + + # Check for book metadata edge cases + is_valid_book, msg_book, case_book = self.book_edge_case_handler.validate_and_log( + book_id, book_title, book_description, aggregated_metadata + ) + if not is_valid_book: + self.logger.warning(f"Book metadata validation failed: {msg_book}") + await self._publish_error_result( + review_id, case_book.value, msg_book, correlation_id + ) + return + + # Compute relevance score (generates embeddings and scores) + timestamp_start = __import__('time').time() + + score_result = self.scoring_function.compute_relevance_score( + review_text=preprocessed_review, + book_metadata=book_metadata + ) + + processing_time_ms = int((__import__('time').time() - timestamp_start) * 1000) + + # Check if scoring was successful + if not score_result.is_valid(): + self.logger.error(f"Scoring failed for review {review_id}: {score_result.error_message}") + await self._publish_error_result( + review_id, score_result.error_code, + score_result.error_message, correlation_id + ) + return + + # Publish successful result + await self._publish_result( + review_id, score_result, processing_time_ms, correlation_id + ) + + except Exception as e: + self.logger.error(f"Error scoring review {review_id}: {e}", exc_info=True) + await self._publish_error_result( + review_id, "INTERNAL_ERROR", str(e), correlation_id + ) + + async def _publish_result(self, review_id: str, score_result, + processing_time_ms: int, correlation_id: str): + """ + Publish a successful scoring result. + + Args: + review_id: Review identifier + score_result: RelevanceScore dataclass with scoring results + processing_time_ms: Time taken to score + correlation_id: Correlation ID for tracing + """ + try: + # Explicitly ensure all string fields are Python str type (not bytes or other types) + result = { + 'reviewId': str(review_id), + 'score': float(score_result.score) if score_result.score is not None else None, + 'modelVersion': str(f"{config.SENTENCE_TRANSFORMER_MODEL}@{config.SENTENCE_TRANSFORMER_MODEL_VERSION}"), + 'rawCosineSimilarity': float(score_result.raw_cosine) if score_result.raw_cosine is not None else None, + 'confidence': float(score_result.confidence) if score_result.confidence is not None else None, + 'errorCode': None, # Explicitly None for optional field + 'errorMessage': None, # Explicitly None for optional field + 'timestamp': int(__import__('time').time() * 1000), + 'processingTimeMs': int(processing_time_ms), + } + + # Use SerializingProducer to publish Avro-encoded message + self.producer.produce( + topic=config.KAFKA_REVIEW_SCORING_RESULT_TOPIC, + key=str(review_id), + value=result + ) + + # Flush to ensure message is sent (with timeout) + self.producer.flush(timeout=5) + + self.logger.info(f"Published scoring result for review={review_id} " + f"score={result['score']:.3f} correlation={correlation_id}") + + except Exception as e: + self.logger.error(f"Failed to publish result for review {review_id}: {e}", exc_info=True) + # Publish to DLQ as fallback + await self._publish_to_dlq(review_id, f"Failed to publish result: {e}", correlation_id) + + async def _publish_error_result(self, review_id: str, error_code: str, + error_message: str, correlation_id: str): + """ + Publish an error result when scoring fails. + + Args: + review_id: Review identifier + error_code: Standardized error code + error_message: Human-readable error description + correlation_id: Correlation ID for tracing + """ + try: + # Explicitly ensure all string fields are Python str type (not bytes or other types) + result = { + 'reviewId': str(review_id), + 'score': None, # Explicitly None for optional field + 'modelVersion': str(f"{config.SENTENCE_TRANSFORMER_MODEL}@{config.SENTENCE_TRANSFORMER_MODEL_VERSION}"), + 'rawCosineSimilarity': None, # Explicitly None for optional field + 'confidence': None, # Explicitly None for optional field + 'errorCode': str(error_code) if error_code else None, + 'errorMessage': str(error_message) if error_message else None, + 'timestamp': int(__import__('time').time() * 1000), + 'processingTimeMs': 0, + } + + # Use SerializingProducer to publish Avro-encoded message + self.producer.produce( + topic=config.KAFKA_REVIEW_SCORING_RESULT_TOPIC, + key=str(review_id), + value=result + ) + + # Flush to ensure message is sent + self.producer.flush(timeout=5) + + self.logger.warning(f"Published error result for review={review_id} " + f"error={error_code} correlation={correlation_id}") + + except Exception as e: + self.logger.error(f"Failed to publish error result for review {review_id}: {e}", exc_info=True) + await self._publish_to_dlq(review_id, f"Failed to publish error: {e}", correlation_id) + + async def _publish_to_dlq(self, review_id: str, message: str, correlation_id: str): + """ + Publish to dead-letter queue when normal processing fails. + Publishes as JSON strings since DLQ doesn't need Avro schema. + + Args: + review_id: Review identifier + message: Error message + correlation_id: Correlation ID for tracing + """ + try: + dlq_message = { + 'reviewId': str(review_id), + 'errorMessage': str(message), + 'correlationId': str(correlation_id) if correlation_id else None, + 'timestamp': int(__import__('time').time() * 1000), + } + + # Use low-level Producer for DLQ (just bytes, no schema) + dlq_producer = Producer({ + 'bootstrap.servers': ','.join(config.KAFKA_BOOTSTRAP_SERVERS) + }) + + dlq_producer.produce( + topic=config.KAFKA_REVIEW_SCORING_DLQ_TOPIC, + key=str(review_id).encode('utf-8'), + value=json.dumps(dlq_message).encode('utf-8') + ) + + dlq_producer.flush() + + self.logger.error(f"Published to DLQ for review={review_id} correlation={correlation_id}") + + except Exception as e: + self.logger.error(f"Failed to publish to DLQ for review {review_id}: {e}", exc_info=True) + + async def stop(self): + """Gracefully stop the consumer and producer.""" + try: + if self.consumer: + self.consumer.close() + if self.producer: + self.producer.flush() + self.logger.info("ReviewScoringConsumer stopped") + except Exception as e: + self.logger.error(f"Error stopping consumer: {e}", exc_info=True) + + +# Global consumer instance +_review_scoring_consumer: Optional[ReviewScoringConsumer] = None + + +async def start_review_scoring_consumer(): + """Initialize and start the review scoring consumer.""" + global _review_scoring_consumer + try: + _review_scoring_consumer = ReviewScoringConsumer() + await _review_scoring_consumer.start() + except Exception as e: + logger.error(f"Failed to start review scoring consumer: {e}", exc_info=True) + raise + + +async def stop_review_scoring_consumer(): + """Gracefully stop the review scoring consumer.""" + global _review_scoring_consumer + if _review_scoring_consumer: + await _review_scoring_consumer.stop() diff --git a/recommenderService/test_sentence_transformer.py b/recommenderService/test_sentence_transformer.py new file mode 100644 index 0000000..b011364 --- /dev/null +++ b/recommenderService/test_sentence_transformer.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +""" +Test Script: Sentence Transformer Integration (Task 1.9) +Purpose: Verify sentence-transformers model loading, embedding generation, +latency, and memory usage before production integration. + +Run: python test_sentence_transformer.py +""" + +import sys +import time +import tracemalloc +from pathlib import Path + +# Add recommenderService to path for imports +sys.path.insert(0, str(Path(__file__).parent)) + + +def get_current_memory_mb(): + """Get current memory usage in MB using tracemalloc""" + current, peak = tracemalloc.get_traced_memory() + return current / 1024 / 1024, peak / 1024 / 1024 + + +def test_sentence_transformer(): + """Test sentence-transformers integration""" + + # Start memory tracing + tracemalloc.start() + + print("=" * 70) + print("SENTENCE TRANSFORMER INTEGRATION TEST (Task 1.9)") + print("=" * 70) + print() + + # Test 1: Import SentenceTransformer + print("[1/7] Testing import of SentenceTransformer...") + try: + from sentence_transformers import SentenceTransformer + print("โœ“ Successfully imported SentenceTransformer") + except ImportError as e: + print(f"โœ— Import failed: {e}") + return False + print() + + # Test 2: Memory baseline + print("[2/7] Recording baseline memory usage...") + memory_before, _ = get_current_memory_mb() + print(f"โœ“ Baseline memory: {memory_before:.2f} MB") + print() + + # Test 3: Load model + print("[3/7] Loading 'all-MiniLM-L6-v2' model...") + start_load = time.time() + try: + model = SentenceTransformer('all-MiniLM-L6-v2') + load_time = time.time() - start_load + print(f"โœ“ Model loaded successfully in {load_time:.2f} seconds") + except Exception as e: + print(f"โœ— Model loading failed: {e}") + return False + print() + + # Test 4: Memory after loading + print("[4/7] Measuring memory footprint after loading...") + memory_after, peak_memory = get_current_memory_mb() + memory_used = memory_after - memory_before + print(f"โœ“ Memory after load: {memory_after:.2f} MB") + print(f"โœ“ Peak memory usage: {peak_memory:.2f} MB") + print(f"โœ“ Model footprint (estimated): {memory_used:.2f} MB") + if peak_memory > 500: + print(f"โš  Warning: Peak memory ({peak_memory:.2f} MB) exceeds soft limit (500 MB)") + print() + + # Test 5: Generate sample embeddings + print("[5/7] Generating 10 sample embeddings (5 reviews + 5 book descriptions)...") + try: + sample_texts = [ + # Review texts + "This book is absolutely amazing and I loved every page of it!", + "The protagonist's character development was outstanding throughout the story.", + "I found this book to be predictable and somewhat disappointing.", + "The writing style was beautiful and kept me engaged until the end.", + "A thought-provoking narrative that challenges conventional thinking.", + # Book description texts + "A thrilling adventure story set in a dystopian future.", + "This novel explores themes of love, loss, and redemption.", + "An epic fantasy saga with complex characters and intricate world-building.", + "A mystery thriller with unexpected twists at every turn.", + "A profound meditation on human nature and morality." + ] + + embeddings = model.encode(sample_texts) + embedding_shape = embeddings.shape + print(f"โœ“ Generated {len(embeddings)} embeddings") + print(f"โœ“ Embedding shape: {embedding_shape}") + print(f"โœ“ Expected dimension: 384, Actual: {embedding_shape[1]}") + + if embedding_shape[1] != 384: + print(f"โœ— Embedding dimension mismatch! Expected 384, got {embedding_shape[1]}") + return False + else: + print(f"โœ“ Embedding dimension correct!") + except Exception as e: + print(f"โœ— Embedding generation failed: {e}") + return False + print() + + # Test 6: Latency test (100 predictions with batch processing) + print("[6/7] Performance test: 100 predictions (batch processing)...") + latency_texts = [ + "This is a test review for the book.", + "The book exceeded my expectations.", + ] + + try: + start_latency = time.time() + # Batch process: more efficient than individual calls + for _ in range(5): # 5 batches ร— 20 texts = 100 predictions + _ = model.encode(latency_texts * 10, convert_to_numpy=True) + total_latency = time.time() - start_latency + avg_latency_per_prediction = (total_latency / 100) * 1000 # Convert to ms + + print(f"โœ“ 100 predictions completed in {total_latency:.3f} seconds") + print(f"โœ“ Average latency: {avg_latency_per_prediction:.2f} ms per prediction") + + # Note: CPU latency thresholds are more generous than GPU + # GPU: ~5ms per prediction, CPU: ~10-15ms per prediction + if total_latency > 2.0: + print(f"โš  Warning: Total latency ({total_latency:.3f}s) is slower than expected") + else: + print(f"โœ“ Latency acceptable for CPU inference") + except Exception as e: + print(f"โœ— Latency test failed: {e}") + return False + print() + + # Test 7: Summary and validation + print("[7/7] VALIDATION SUMMARY") + print("-" * 70) + validation_results = { + "Model import successful": True, + "Model loads successfully": load_time > 0, + "Memory footprint reasonable (< 300MB total)": memory_used < 300, + "Embedding dimension correct (384)": embedding_shape[1] == 384, + "Inference latency acceptable (CPU)": total_latency < 2.0, + } + + all_passed = True + for check, passed in validation_results.items(): + status = "โœ“" if passed else "โœ—" + print(f"{status} {check}") + if not passed: + all_passed = False + + print("-" * 70) + print() + + # Final result + if all_passed: + print("=" * 70) + print("SUCCESS! All tests passed. โœ“") + print("=" * 70) + print() + print("SUMMARY STATISTICS:") + print(f" Model Load Time (cached): {load_time:.2f} seconds") + print(f" Memory Baseline: {memory_before:.2f} MB") + print(f" Memory After Load: {memory_after:.2f} MB") + print(f" Memory Increase: {memory_used:.2f} MB") + print(f" Peak Memory: {peak_memory:.2f} MB") + print(f" Embedding Dimension: {embedding_shape[1]}") + print(f" 100 Predictions Latency: {total_latency:.3f} seconds ({avg_latency_per_prediction:.2f} ms/pred)") + print() + print("NOTE: Running on CPU. GPU deployment would see ~2-3x faster latency.") + print("Ready for Phase 2 implementation! ๐Ÿš€") + print("=" * 70) + return True + else: + print("=" * 70) + print("FAILURE: Some tests did not pass. โœ—") + print("=" * 70) + return False + + +if __name__ == "__main__": + try: + success = test_sentence_transformer() + sys.exit(0 if success else 1) + except Exception as e: + print(f"\nUnexpected error: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/reviewService/src/main/java/event/BookDeletedEvent.java b/reviewService/src/main/java/event/BookDeletedEvent.java index 7650c81..52db55d 100644 --- a/reviewService/src/main/java/event/BookDeletedEvent.java +++ b/reviewService/src/main/java/event/BookDeletedEvent.java @@ -6,7 +6,6 @@ package event; import org.apache.avro.specific.SpecificData; -import org.apache.avro.util.Utf8; import org.apache.avro.message.BinaryMessageEncoder; import org.apache.avro.message.BinaryMessageDecoder; import org.apache.avro.message.SchemaStore; diff --git a/reviewService/src/main/java/event/BookRatingUpdatedEvent.java b/reviewService/src/main/java/event/BookRatingUpdatedEvent.java index 65138b5..d8cd5cb 100644 --- a/reviewService/src/main/java/event/BookRatingUpdatedEvent.java +++ b/reviewService/src/main/java/event/BookRatingUpdatedEvent.java @@ -6,7 +6,6 @@ package event; import org.apache.avro.specific.SpecificData; -import org.apache.avro.util.Utf8; import org.apache.avro.message.BinaryMessageEncoder; import org.apache.avro.message.BinaryMessageDecoder; import org.apache.avro.message.SchemaStore; diff --git a/reviewService/src/main/java/org/library/reviewService/ReviewServiceApplication.java b/reviewService/src/main/java/org/library/reviewService/ReviewServiceApplication.java index d621b1a..23198e3 100644 --- a/reviewService/src/main/java/org/library/reviewService/ReviewServiceApplication.java +++ b/reviewService/src/main/java/org/library/reviewService/ReviewServiceApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication @EnableFeignClients +@EnableAsync public class ReviewServiceApplication { public static void main(String[] args) { diff --git a/reviewService/src/main/java/org/library/reviewService/client/response/BookResponse.java b/reviewService/src/main/java/org/library/reviewService/client/response/BookResponse.java index 189c951..149710e 100644 --- a/reviewService/src/main/java/org/library/reviewService/client/response/BookResponse.java +++ b/reviewService/src/main/java/org/library/reviewService/client/response/BookResponse.java @@ -3,12 +3,85 @@ import lombok.Data; import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +/** + * Book response DTO matching the bookService /api/books/{bookId} response. + * This DTO is used to deserialize the API response from bookService. + */ @Data public class BookResponse { private Integer id; private String title; + + /** + * Author object - contains id, name, bio + */ + private AuthorResponse author; + + /** + * Category object - contains id, name, description + */ + private CategoryResponse category; + private String description; private String ISBN; + + /** + * Publisher object - contains id, name, country + */ + private PublisherResponse publisher; + + private LocalDate releaseDate; private BigDecimal price; + private String imageUrl; + + /** + * List of genre objects - each contains id, name, description + */ + private List bookGenres; + + private Double averageRating; + private Integer totalReviews; + + /** + * Simple DTO for Author - extracted from author object + */ + @Data + public static class AuthorResponse { + private Integer id; + private String name; + private String bio; + } + + /** + * Simple DTO for Category - extracted from category object + */ + @Data + public static class CategoryResponse { + private Integer id; + private String name; + private String description; + } + + /** + * Simple DTO for Publisher - extracted from publisher object + */ + @Data + public static class PublisherResponse { + private Integer id; + private String name; + private String country; + } + + /** + * Simple DTO for Genre - extracted from bookGenres list + */ + @Data + public static class GenreResponse { + private Integer id; + private String name; + private String description; + } } diff --git a/reviewService/src/main/java/org/library/reviewService/config/SecurityConfig.java b/reviewService/src/main/java/org/library/reviewService/config/SecurityConfig.java index efc10d7..2c158ed 100644 --- a/reviewService/src/main/java/org/library/reviewService/config/SecurityConfig.java +++ b/reviewService/src/main/java/org/library/reviewService/config/SecurityConfig.java @@ -1,6 +1,5 @@ package org.library.reviewService.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,19 +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; - private final String[] freeResourceUrls = { "/swagger-ui.html/**", "/swagger-ui/**", diff --git a/reviewService/src/main/java/org/library/reviewService/datagen/InitialDataGenerator.java b/reviewService/src/main/java/org/library/reviewService/datagen/InitialDataGenerator.java index 90579fb..5b63f56 100644 --- a/reviewService/src/main/java/org/library/reviewService/datagen/InitialDataGenerator.java +++ b/reviewService/src/main/java/org/library/reviewService/datagen/InitialDataGenerator.java @@ -2,13 +2,15 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.library.reviewService.model.Review; import org.library.reviewService.repository.ReviewRepository; import org.library.reviewService.service.ReviewMetricsService; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import java.io.FileInputStream; @@ -34,8 +36,17 @@ public class InitialDataGenerator { @Value("${data.seeding.reviews}") private String reviewsFile; - @PostConstruct - public void initializeDbWithTestData() { + @EventListener(ApplicationReadyEvent.class) + @Async + public void onApplicationReady() { + try { + // Add a small delay to ensure Schema Registry and other services are ready + Thread.sleep(3000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("Data seeding sleep was interrupted", e); + } + if (reviewRepository.count() > 0) { log.info("Database already initialized. Skipping data seeding."); return; diff --git a/reviewService/src/main/java/org/library/reviewService/event/ReviewScoringRequest.java b/reviewService/src/main/java/org/library/reviewService/event/ReviewScoringRequest.java new file mode 100644 index 0000000..1d6402b --- /dev/null +++ b/reviewService/src/main/java/org/library/reviewService/event/ReviewScoringRequest.java @@ -0,0 +1,846 @@ +/** + * Autogenerated by Avro + * + * DO NOT EDIT DIRECTLY + */ +package org.library.reviewService.event; + +import org.apache.avro.specific.SpecificData; +import org.apache.avro.util.Utf8; +import org.apache.avro.message.BinaryMessageEncoder; +import org.apache.avro.message.BinaryMessageDecoder; +import org.apache.avro.message.SchemaStore; + +/** Request to score a book review for relevance using semantic similarity */ +@org.apache.avro.specific.AvroGenerated +public class ReviewScoringRequest extends org.apache.avro.specific.SpecificRecordBase implements org.apache.avro.specific.SpecificRecord { + private static final long serialVersionUID = -2068139988993257172L; + + + public static final org.apache.avro.Schema SCHEMA$ = new org.apache.avro.Schema.Parser().parse("{\"type\":\"record\",\"name\":\"ReviewScoringRequest\",\"namespace\":\"org.library.reviewService.event\",\"doc\":\"Request to score a book review for relevance using semantic similarity\",\"fields\":[{\"name\":\"reviewId\",\"type\":\"string\",\"doc\":\"Unique identifier of the review to be scored\"},{\"name\":\"bookId\",\"type\":\"int\",\"doc\":\"Unique identifier of the book being reviewed\"},{\"name\":\"reviewText\",\"type\":\"string\",\"doc\":\"The raw review text to be scored\"},{\"name\":\"bookMetadata\",\"type\":\"string\",\"doc\":\"Aggregated book metadata as JSON string (title, author, category, genres, description, ISBN) for semantic context\"},{\"name\":\"userId\",\"type\":\"string\",\"doc\":\"Unique identifier of the user who wrote the review\"},{\"name\":\"timestamp\",\"type\":\"long\",\"doc\":\"Unix timestamp (milliseconds) when review was created\"},{\"name\":\"correlationId\",\"type\":\"string\",\"doc\":\"Correlation ID for distributed tracing across services\"}]}"); + public static org.apache.avro.Schema getClassSchema() { return SCHEMA$; } + + private static final SpecificData MODEL$ = new SpecificData(); + + private static final BinaryMessageEncoder ENCODER = + new BinaryMessageEncoder<>(MODEL$, SCHEMA$); + + private static final BinaryMessageDecoder DECODER = + new BinaryMessageDecoder<>(MODEL$, SCHEMA$); + + /** + * Return the BinaryMessageEncoder instance used by this class. + * @return the message encoder used by this class + */ + public static BinaryMessageEncoder getEncoder() { + return ENCODER; + } + + /** + * Return the BinaryMessageDecoder instance used by this class. + * @return the message decoder used by this class + */ + public static BinaryMessageDecoder getDecoder() { + return DECODER; + } + + /** + * Create a new BinaryMessageDecoder instance for this class that uses the specified {@link SchemaStore}. + * @param resolver a {@link SchemaStore} used to find schemas by fingerprint + * @return a BinaryMessageDecoder instance for this class backed by the given SchemaStore + */ + public static BinaryMessageDecoder createDecoder(SchemaStore resolver) { + return new BinaryMessageDecoder<>(MODEL$, SCHEMA$, resolver); + } + + /** + * Serializes this ReviewScoringRequest to a ByteBuffer. + * @return a buffer holding the serialized data for this instance + * @throws java.io.IOException if this instance could not be serialized + */ + public java.nio.ByteBuffer toByteBuffer() throws java.io.IOException { + return ENCODER.encode(this); + } + + /** + * Deserializes a ReviewScoringRequest from a ByteBuffer. + * @param b a byte buffer holding serialized data for an instance of this class + * @return a ReviewScoringRequest instance decoded from the given buffer + * @throws java.io.IOException if the given bytes could not be deserialized into an instance of this class + */ + public static ReviewScoringRequest fromByteBuffer( + java.nio.ByteBuffer b) throws java.io.IOException { + return DECODER.decode(b); + } + + /** Unique identifier of the review to be scored */ + private java.lang.CharSequence reviewId; + /** Unique identifier of the book being reviewed */ + private int bookId; + /** The raw review text to be scored */ + private java.lang.CharSequence reviewText; + /** Aggregated book metadata as JSON string (title, author, category, genres, description, ISBN) for semantic context */ + private java.lang.CharSequence bookMetadata; + /** Unique identifier of the user who wrote the review */ + private java.lang.CharSequence userId; + /** Unix timestamp (milliseconds) when review was created */ + private long timestamp; + /** Correlation ID for distributed tracing across services */ + private java.lang.CharSequence correlationId; + + /** + * Default constructor. Note that this does not initialize fields + * to their default values from the schema. If that is desired then + * one should use newBuilder(). + */ + public ReviewScoringRequest() {} + + /** + * All-args constructor. + * @param reviewId Unique identifier of the review to be scored + * @param bookId Unique identifier of the book being reviewed + * @param reviewText The raw review text to be scored + * @param bookMetadata Aggregated book metadata as JSON string (title, author, category, genres, description, ISBN) for semantic context + * @param userId Unique identifier of the user who wrote the review + * @param timestamp Unix timestamp (milliseconds) when review was created + * @param correlationId Correlation ID for distributed tracing across services + */ + public ReviewScoringRequest(java.lang.CharSequence reviewId, java.lang.Integer bookId, java.lang.CharSequence reviewText, java.lang.CharSequence bookMetadata, java.lang.CharSequence userId, java.lang.Long timestamp, java.lang.CharSequence correlationId) { + this.reviewId = reviewId; + this.bookId = bookId; + this.reviewText = reviewText; + this.bookMetadata = bookMetadata; + this.userId = userId; + this.timestamp = timestamp; + this.correlationId = correlationId; + } + + @Override + public org.apache.avro.specific.SpecificData getSpecificData() { return MODEL$; } + + @Override + public org.apache.avro.Schema getSchema() { return SCHEMA$; } + + // Used by DatumWriter. Applications should not call. + @Override + public java.lang.Object get(int field$) { + switch (field$) { + case 0: return reviewId; + case 1: return bookId; + case 2: return reviewText; + case 3: return bookMetadata; + case 4: return userId; + case 5: return timestamp; + case 6: return correlationId; + default: throw new IndexOutOfBoundsException("Invalid index: " + field$); + } + } + + // Used by DatumReader. Applications should not call. + @Override + @SuppressWarnings(value="unchecked") + public void put(int field$, java.lang.Object value$) { + switch (field$) { + case 0: reviewId = (java.lang.CharSequence)value$; break; + case 1: bookId = (java.lang.Integer)value$; break; + case 2: reviewText = (java.lang.CharSequence)value$; break; + case 3: bookMetadata = (java.lang.CharSequence)value$; break; + case 4: userId = (java.lang.CharSequence)value$; break; + case 5: timestamp = (java.lang.Long)value$; break; + case 6: correlationId = (java.lang.CharSequence)value$; break; + default: throw new IndexOutOfBoundsException("Invalid index: " + field$); + } + } + + /** + * Gets the value of the 'reviewId' field. + * @return Unique identifier of the review to be scored + */ + public java.lang.CharSequence getReviewId() { + return reviewId; + } + + + /** + * Sets the value of the 'reviewId' field. + * Unique identifier of the review to be scored + * @param value the value to set. + */ + public void setReviewId(java.lang.CharSequence value) { + this.reviewId = value; + } + + /** + * Gets the value of the 'bookId' field. + * @return Unique identifier of the book being reviewed + */ + public int getBookId() { + return bookId; + } + + + /** + * Sets the value of the 'bookId' field. + * Unique identifier of the book being reviewed + * @param value the value to set. + */ + public void setBookId(int value) { + this.bookId = value; + } + + /** + * Gets the value of the 'reviewText' field. + * @return The raw review text to be scored + */ + public java.lang.CharSequence getReviewText() { + return reviewText; + } + + + /** + * Sets the value of the 'reviewText' field. + * The raw review text to be scored + * @param value the value to set. + */ + public void setReviewText(java.lang.CharSequence value) { + this.reviewText = value; + } + + /** + * Gets the value of the 'bookMetadata' field. + * @return Aggregated book metadata as JSON string (title, author, category, genres, description, ISBN) for semantic context + */ + public java.lang.CharSequence getBookMetadata() { + return bookMetadata; + } + + + /** + * Sets the value of the 'bookMetadata' field. + * Aggregated book metadata as JSON string (title, author, category, genres, description, ISBN) for semantic context + * @param value the value to set. + */ + public void setBookMetadata(java.lang.CharSequence value) { + this.bookMetadata = value; + } + + /** + * Gets the value of the 'userId' field. + * @return Unique identifier of the user who wrote the review + */ + public java.lang.CharSequence getUserId() { + return userId; + } + + + /** + * Sets the value of the 'userId' field. + * Unique identifier of the user who wrote the review + * @param value the value to set. + */ + public void setUserId(java.lang.CharSequence value) { + this.userId = value; + } + + /** + * Gets the value of the 'timestamp' field. + * @return Unix timestamp (milliseconds) when review was created + */ + public long getTimestamp() { + return timestamp; + } + + + /** + * Sets the value of the 'timestamp' field. + * Unix timestamp (milliseconds) when review was created + * @param value the value to set. + */ + public void setTimestamp(long value) { + this.timestamp = value; + } + + /** + * Gets the value of the 'correlationId' field. + * @return Correlation ID for distributed tracing across services + */ + public java.lang.CharSequence getCorrelationId() { + return correlationId; + } + + + /** + * Sets the value of the 'correlationId' field. + * Correlation ID for distributed tracing across services + * @param value the value to set. + */ + public void setCorrelationId(java.lang.CharSequence value) { + this.correlationId = value; + } + + /** + * Creates a new ReviewScoringRequest RecordBuilder. + * @return A new ReviewScoringRequest RecordBuilder + */ + public static org.library.reviewService.event.ReviewScoringRequest.Builder newBuilder() { + return new org.library.reviewService.event.ReviewScoringRequest.Builder(); + } + + /** + * Creates a new ReviewScoringRequest RecordBuilder by copying an existing Builder. + * @param other The existing builder to copy. + * @return A new ReviewScoringRequest RecordBuilder + */ + public static org.library.reviewService.event.ReviewScoringRequest.Builder newBuilder(org.library.reviewService.event.ReviewScoringRequest.Builder other) { + if (other == null) { + return new org.library.reviewService.event.ReviewScoringRequest.Builder(); + } else { + return new org.library.reviewService.event.ReviewScoringRequest.Builder(other); + } + } + + /** + * Creates a new ReviewScoringRequest RecordBuilder by copying an existing ReviewScoringRequest instance. + * @param other The existing instance to copy. + * @return A new ReviewScoringRequest RecordBuilder + */ + public static org.library.reviewService.event.ReviewScoringRequest.Builder newBuilder(org.library.reviewService.event.ReviewScoringRequest other) { + if (other == null) { + return new org.library.reviewService.event.ReviewScoringRequest.Builder(); + } else { + return new org.library.reviewService.event.ReviewScoringRequest.Builder(other); + } + } + + /** + * RecordBuilder for ReviewScoringRequest instances. + */ + @org.apache.avro.specific.AvroGenerated + public static class Builder extends org.apache.avro.specific.SpecificRecordBuilderBase + implements org.apache.avro.data.RecordBuilder { + + /** Unique identifier of the review to be scored */ + private java.lang.CharSequence reviewId; + /** Unique identifier of the book being reviewed */ + private int bookId; + /** The raw review text to be scored */ + private java.lang.CharSequence reviewText; + /** Aggregated book metadata as JSON string (title, author, category, genres, description, ISBN) for semantic context */ + private java.lang.CharSequence bookMetadata; + /** Unique identifier of the user who wrote the review */ + private java.lang.CharSequence userId; + /** Unix timestamp (milliseconds) when review was created */ + private long timestamp; + /** Correlation ID for distributed tracing across services */ + private java.lang.CharSequence correlationId; + + /** Creates a new Builder */ + private Builder() { + super(SCHEMA$, MODEL$); + } + + /** + * Creates a Builder by copying an existing Builder. + * @param other The existing Builder to copy. + */ + private Builder(org.library.reviewService.event.ReviewScoringRequest.Builder other) { + super(other); + if (isValidValue(fields()[0], other.reviewId)) { + this.reviewId = data().deepCopy(fields()[0].schema(), other.reviewId); + fieldSetFlags()[0] = other.fieldSetFlags()[0]; + } + if (isValidValue(fields()[1], other.bookId)) { + this.bookId = data().deepCopy(fields()[1].schema(), other.bookId); + fieldSetFlags()[1] = other.fieldSetFlags()[1]; + } + if (isValidValue(fields()[2], other.reviewText)) { + this.reviewText = data().deepCopy(fields()[2].schema(), other.reviewText); + fieldSetFlags()[2] = other.fieldSetFlags()[2]; + } + if (isValidValue(fields()[3], other.bookMetadata)) { + this.bookMetadata = data().deepCopy(fields()[3].schema(), other.bookMetadata); + fieldSetFlags()[3] = other.fieldSetFlags()[3]; + } + if (isValidValue(fields()[4], other.userId)) { + this.userId = data().deepCopy(fields()[4].schema(), other.userId); + fieldSetFlags()[4] = other.fieldSetFlags()[4]; + } + if (isValidValue(fields()[5], other.timestamp)) { + this.timestamp = data().deepCopy(fields()[5].schema(), other.timestamp); + fieldSetFlags()[5] = other.fieldSetFlags()[5]; + } + if (isValidValue(fields()[6], other.correlationId)) { + this.correlationId = data().deepCopy(fields()[6].schema(), other.correlationId); + fieldSetFlags()[6] = other.fieldSetFlags()[6]; + } + } + + /** + * Creates a Builder by copying an existing ReviewScoringRequest instance + * @param other The existing instance to copy. + */ + private Builder(org.library.reviewService.event.ReviewScoringRequest other) { + super(SCHEMA$, MODEL$); + if (isValidValue(fields()[0], other.reviewId)) { + this.reviewId = data().deepCopy(fields()[0].schema(), other.reviewId); + fieldSetFlags()[0] = true; + } + if (isValidValue(fields()[1], other.bookId)) { + this.bookId = data().deepCopy(fields()[1].schema(), other.bookId); + fieldSetFlags()[1] = true; + } + if (isValidValue(fields()[2], other.reviewText)) { + this.reviewText = data().deepCopy(fields()[2].schema(), other.reviewText); + fieldSetFlags()[2] = true; + } + if (isValidValue(fields()[3], other.bookMetadata)) { + this.bookMetadata = data().deepCopy(fields()[3].schema(), other.bookMetadata); + fieldSetFlags()[3] = true; + } + if (isValidValue(fields()[4], other.userId)) { + this.userId = data().deepCopy(fields()[4].schema(), other.userId); + fieldSetFlags()[4] = true; + } + if (isValidValue(fields()[5], other.timestamp)) { + this.timestamp = data().deepCopy(fields()[5].schema(), other.timestamp); + fieldSetFlags()[5] = true; + } + if (isValidValue(fields()[6], other.correlationId)) { + this.correlationId = data().deepCopy(fields()[6].schema(), other.correlationId); + fieldSetFlags()[6] = true; + } + } + + /** + * Gets the value of the 'reviewId' field. + * Unique identifier of the review to be scored + * @return The value. + */ + public java.lang.CharSequence getReviewId() { + return reviewId; + } + + + /** + * Sets the value of the 'reviewId' field. + * Unique identifier of the review to be scored + * @param value The value of 'reviewId'. + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringRequest.Builder setReviewId(java.lang.CharSequence value) { + validate(fields()[0], value); + this.reviewId = value; + fieldSetFlags()[0] = true; + return this; + } + + /** + * Checks whether the 'reviewId' field has been set. + * Unique identifier of the review to be scored + * @return True if the 'reviewId' field has been set, false otherwise. + */ + public boolean hasReviewId() { + return fieldSetFlags()[0]; + } + + + /** + * Clears the value of the 'reviewId' field. + * Unique identifier of the review to be scored + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringRequest.Builder clearReviewId() { + reviewId = null; + fieldSetFlags()[0] = false; + return this; + } + + /** + * Gets the value of the 'bookId' field. + * Unique identifier of the book being reviewed + * @return The value. + */ + public int getBookId() { + return bookId; + } + + + /** + * Sets the value of the 'bookId' field. + * Unique identifier of the book being reviewed + * @param value The value of 'bookId'. + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringRequest.Builder setBookId(int value) { + validate(fields()[1], value); + this.bookId = value; + fieldSetFlags()[1] = true; + return this; + } + + /** + * Checks whether the 'bookId' field has been set. + * Unique identifier of the book being reviewed + * @return True if the 'bookId' field has been set, false otherwise. + */ + public boolean hasBookId() { + return fieldSetFlags()[1]; + } + + + /** + * Clears the value of the 'bookId' field. + * Unique identifier of the book being reviewed + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringRequest.Builder clearBookId() { + fieldSetFlags()[1] = false; + return this; + } + + /** + * Gets the value of the 'reviewText' field. + * The raw review text to be scored + * @return The value. + */ + public java.lang.CharSequence getReviewText() { + return reviewText; + } + + + /** + * Sets the value of the 'reviewText' field. + * The raw review text to be scored + * @param value The value of 'reviewText'. + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringRequest.Builder setReviewText(java.lang.CharSequence value) { + validate(fields()[2], value); + this.reviewText = value; + fieldSetFlags()[2] = true; + return this; + } + + /** + * Checks whether the 'reviewText' field has been set. + * The raw review text to be scored + * @return True if the 'reviewText' field has been set, false otherwise. + */ + public boolean hasReviewText() { + return fieldSetFlags()[2]; + } + + + /** + * Clears the value of the 'reviewText' field. + * The raw review text to be scored + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringRequest.Builder clearReviewText() { + reviewText = null; + fieldSetFlags()[2] = false; + return this; + } + + /** + * Gets the value of the 'bookMetadata' field. + * Aggregated book metadata as JSON string (title, author, category, genres, description, ISBN) for semantic context + * @return The value. + */ + public java.lang.CharSequence getBookMetadata() { + return bookMetadata; + } + + + /** + * Sets the value of the 'bookMetadata' field. + * Aggregated book metadata as JSON string (title, author, category, genres, description, ISBN) for semantic context + * @param value The value of 'bookMetadata'. + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringRequest.Builder setBookMetadata(java.lang.CharSequence value) { + validate(fields()[3], value); + this.bookMetadata = value; + fieldSetFlags()[3] = true; + return this; + } + + /** + * Checks whether the 'bookMetadata' field has been set. + * Aggregated book metadata as JSON string (title, author, category, genres, description, ISBN) for semantic context + * @return True if the 'bookMetadata' field has been set, false otherwise. + */ + public boolean hasBookMetadata() { + return fieldSetFlags()[3]; + } + + + /** + * Clears the value of the 'bookMetadata' field. + * Aggregated book metadata as JSON string (title, author, category, genres, description, ISBN) for semantic context + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringRequest.Builder clearBookMetadata() { + bookMetadata = null; + fieldSetFlags()[3] = false; + return this; + } + + /** + * Gets the value of the 'userId' field. + * Unique identifier of the user who wrote the review + * @return The value. + */ + public java.lang.CharSequence getUserId() { + return userId; + } + + + /** + * Sets the value of the 'userId' field. + * Unique identifier of the user who wrote the review + * @param value The value of 'userId'. + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringRequest.Builder setUserId(java.lang.CharSequence value) { + validate(fields()[4], value); + this.userId = value; + fieldSetFlags()[4] = true; + return this; + } + + /** + * Checks whether the 'userId' field has been set. + * Unique identifier of the user who wrote the review + * @return True if the 'userId' field has been set, false otherwise. + */ + public boolean hasUserId() { + return fieldSetFlags()[4]; + } + + + /** + * Clears the value of the 'userId' field. + * Unique identifier of the user who wrote the review + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringRequest.Builder clearUserId() { + userId = null; + fieldSetFlags()[4] = false; + return this; + } + + /** + * Gets the value of the 'timestamp' field. + * Unix timestamp (milliseconds) when review was created + * @return The value. + */ + public long getTimestamp() { + return timestamp; + } + + + /** + * Sets the value of the 'timestamp' field. + * Unix timestamp (milliseconds) when review was created + * @param value The value of 'timestamp'. + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringRequest.Builder setTimestamp(long value) { + validate(fields()[5], value); + this.timestamp = value; + fieldSetFlags()[5] = true; + return this; + } + + /** + * Checks whether the 'timestamp' field has been set. + * Unix timestamp (milliseconds) when review was created + * @return True if the 'timestamp' field has been set, false otherwise. + */ + public boolean hasTimestamp() { + return fieldSetFlags()[5]; + } + + + /** + * Clears the value of the 'timestamp' field. + * Unix timestamp (milliseconds) when review was created + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringRequest.Builder clearTimestamp() { + fieldSetFlags()[5] = false; + return this; + } + + /** + * Gets the value of the 'correlationId' field. + * Correlation ID for distributed tracing across services + * @return The value. + */ + public java.lang.CharSequence getCorrelationId() { + return correlationId; + } + + + /** + * Sets the value of the 'correlationId' field. + * Correlation ID for distributed tracing across services + * @param value The value of 'correlationId'. + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringRequest.Builder setCorrelationId(java.lang.CharSequence value) { + validate(fields()[6], value); + this.correlationId = value; + fieldSetFlags()[6] = true; + return this; + } + + /** + * Checks whether the 'correlationId' field has been set. + * Correlation ID for distributed tracing across services + * @return True if the 'correlationId' field has been set, false otherwise. + */ + public boolean hasCorrelationId() { + return fieldSetFlags()[6]; + } + + + /** + * Clears the value of the 'correlationId' field. + * Correlation ID for distributed tracing across services + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringRequest.Builder clearCorrelationId() { + correlationId = null; + fieldSetFlags()[6] = false; + return this; + } + + @Override + @SuppressWarnings("unchecked") + public ReviewScoringRequest build() { + try { + ReviewScoringRequest record = new ReviewScoringRequest(); + record.reviewId = fieldSetFlags()[0] ? this.reviewId : (java.lang.CharSequence) defaultValue(fields()[0]); + record.bookId = fieldSetFlags()[1] ? this.bookId : (java.lang.Integer) defaultValue(fields()[1]); + record.reviewText = fieldSetFlags()[2] ? this.reviewText : (java.lang.CharSequence) defaultValue(fields()[2]); + record.bookMetadata = fieldSetFlags()[3] ? this.bookMetadata : (java.lang.CharSequence) defaultValue(fields()[3]); + record.userId = fieldSetFlags()[4] ? this.userId : (java.lang.CharSequence) defaultValue(fields()[4]); + record.timestamp = fieldSetFlags()[5] ? this.timestamp : (java.lang.Long) defaultValue(fields()[5]); + record.correlationId = fieldSetFlags()[6] ? this.correlationId : (java.lang.CharSequence) defaultValue(fields()[6]); + return record; + } catch (org.apache.avro.AvroMissingFieldException e) { + throw e; + } catch (java.lang.Exception e) { + throw new org.apache.avro.AvroRuntimeException(e); + } + } + } + + @SuppressWarnings("unchecked") + private static final org.apache.avro.io.DatumWriter + WRITER$ = (org.apache.avro.io.DatumWriter)MODEL$.createDatumWriter(SCHEMA$); + + @Override public void writeExternal(java.io.ObjectOutput out) + throws java.io.IOException { + WRITER$.write(this, SpecificData.getEncoder(out)); + } + + @SuppressWarnings("unchecked") + private static final org.apache.avro.io.DatumReader + READER$ = (org.apache.avro.io.DatumReader)MODEL$.createDatumReader(SCHEMA$); + + @Override public void readExternal(java.io.ObjectInput in) + throws java.io.IOException { + READER$.read(this, SpecificData.getDecoder(in)); + } + + @Override protected boolean hasCustomCoders() { return true; } + + @Override public void customEncode(org.apache.avro.io.Encoder out) + throws java.io.IOException + { + out.writeString(this.reviewId); + + out.writeInt(this.bookId); + + out.writeString(this.reviewText); + + out.writeString(this.bookMetadata); + + out.writeString(this.userId); + + out.writeLong(this.timestamp); + + out.writeString(this.correlationId); + + } + + @Override public void customDecode(org.apache.avro.io.ResolvingDecoder in) + throws java.io.IOException + { + org.apache.avro.Schema.Field[] fieldOrder = in.readFieldOrderIfDiff(); + if (fieldOrder == null) { + this.reviewId = in.readString(this.reviewId instanceof Utf8 ? (Utf8)this.reviewId : null); + + this.bookId = in.readInt(); + + this.reviewText = in.readString(this.reviewText instanceof Utf8 ? (Utf8)this.reviewText : null); + + this.bookMetadata = in.readString(this.bookMetadata instanceof Utf8 ? (Utf8)this.bookMetadata : null); + + this.userId = in.readString(this.userId instanceof Utf8 ? (Utf8)this.userId : null); + + this.timestamp = in.readLong(); + + this.correlationId = in.readString(this.correlationId instanceof Utf8 ? (Utf8)this.correlationId : null); + + } else { + for (int i = 0; i < 7; i++) { + switch (fieldOrder[i].pos()) { + case 0: + this.reviewId = in.readString(this.reviewId instanceof Utf8 ? (Utf8)this.reviewId : null); + break; + + case 1: + this.bookId = in.readInt(); + break; + + case 2: + this.reviewText = in.readString(this.reviewText instanceof Utf8 ? (Utf8)this.reviewText : null); + break; + + case 3: + this.bookMetadata = in.readString(this.bookMetadata instanceof Utf8 ? (Utf8)this.bookMetadata : null); + break; + + case 4: + this.userId = in.readString(this.userId instanceof Utf8 ? (Utf8)this.userId : null); + break; + + case 5: + this.timestamp = in.readLong(); + break; + + case 6: + this.correlationId = in.readString(this.correlationId instanceof Utf8 ? (Utf8)this.correlationId : null); + break; + + default: + throw new java.io.IOException("Corrupt ResolvingDecoder."); + } + } + } + } +} + + + + + + + + + + diff --git a/reviewService/src/main/java/org/library/reviewService/event/ReviewScoringResult.java b/reviewService/src/main/java/org/library/reviewService/event/ReviewScoringResult.java new file mode 100644 index 0000000..17c9d9b --- /dev/null +++ b/reviewService/src/main/java/org/library/reviewService/event/ReviewScoringResult.java @@ -0,0 +1,1100 @@ +/** + * Autogenerated by Avro + * + * DO NOT EDIT DIRECTLY + */ +package org.library.reviewService.event; + +import org.apache.avro.specific.SpecificData; +import org.apache.avro.util.Utf8; +import org.apache.avro.message.BinaryMessageEncoder; +import org.apache.avro.message.BinaryMessageDecoder; +import org.apache.avro.message.SchemaStore; + +/** Result of semantic relevance scoring for a book review */ +@org.apache.avro.specific.AvroGenerated +public class ReviewScoringResult extends org.apache.avro.specific.SpecificRecordBase implements org.apache.avro.specific.SpecificRecord { + private static final long serialVersionUID = 2932923793051144020L; + + + public static final org.apache.avro.Schema SCHEMA$ = new org.apache.avro.Schema.Parser().parse("{\"type\":\"record\",\"name\":\"ReviewScoringResult\",\"namespace\":\"org.library.reviewService.event\",\"doc\":\"Result of semantic relevance scoring for a book review\",\"fields\":[{\"name\":\"reviewId\",\"type\":\"string\",\"doc\":\"Unique identifier of the review that was scored\"},{\"name\":\"score\",\"type\":[\"null\",\"double\"],\"doc\":\"Normalized relevance score [0.0, 1.0], null if scoring failed\",\"default\":null},{\"name\":\"modelVersion\",\"type\":\"string\",\"doc\":\"Version of the semantic model used (e.g., 'all-MiniLM-L6-v2@3.0.0')\"},{\"name\":\"rawCosineSimilarity\",\"type\":[\"null\",\"double\"],\"doc\":\"Raw cosine similarity [-1.0, 1.0] before normalization (debug info)\",\"default\":null},{\"name\":\"confidence\",\"type\":[\"null\",\"double\"],\"doc\":\"Confidence score [0.0, 1.0] based on text quality and model certainty\",\"default\":null},{\"name\":\"errorCode\",\"type\":[\"null\",\"string\"],\"doc\":\"Error code if scoring failed (e.g., 'EMPTY_REVIEW', 'GIBBERISH_TEXT')\",\"default\":null},{\"name\":\"errorMessage\",\"type\":[\"null\",\"string\"],\"doc\":\"Descriptive error message if scoring failed\",\"default\":null},{\"name\":\"timestamp\",\"type\":\"long\",\"doc\":\"Unix timestamp (milliseconds) when scoring completed\"},{\"name\":\"processingTimeMs\",\"type\":\"long\",\"doc\":\"Time taken to score the review in milliseconds\"}]}"); + public static org.apache.avro.Schema getClassSchema() { return SCHEMA$; } + + private static final SpecificData MODEL$ = new SpecificData(); + + private static final BinaryMessageEncoder ENCODER = + new BinaryMessageEncoder<>(MODEL$, SCHEMA$); + + private static final BinaryMessageDecoder DECODER = + new BinaryMessageDecoder<>(MODEL$, SCHEMA$); + + /** + * Return the BinaryMessageEncoder instance used by this class. + * @return the message encoder used by this class + */ + public static BinaryMessageEncoder getEncoder() { + return ENCODER; + } + + /** + * Return the BinaryMessageDecoder instance used by this class. + * @return the message decoder used by this class + */ + public static BinaryMessageDecoder getDecoder() { + return DECODER; + } + + /** + * Create a new BinaryMessageDecoder instance for this class that uses the specified {@link SchemaStore}. + * @param resolver a {@link SchemaStore} used to find schemas by fingerprint + * @return a BinaryMessageDecoder instance for this class backed by the given SchemaStore + */ + public static BinaryMessageDecoder createDecoder(SchemaStore resolver) { + return new BinaryMessageDecoder<>(MODEL$, SCHEMA$, resolver); + } + + /** + * Serializes this ReviewScoringResult to a ByteBuffer. + * @return a buffer holding the serialized data for this instance + * @throws java.io.IOException if this instance could not be serialized + */ + public java.nio.ByteBuffer toByteBuffer() throws java.io.IOException { + return ENCODER.encode(this); + } + + /** + * Deserializes a ReviewScoringResult from a ByteBuffer. + * @param b a byte buffer holding serialized data for an instance of this class + * @return a ReviewScoringResult instance decoded from the given buffer + * @throws java.io.IOException if the given bytes could not be deserialized into an instance of this class + */ + public static ReviewScoringResult fromByteBuffer( + java.nio.ByteBuffer b) throws java.io.IOException { + return DECODER.decode(b); + } + + /** Unique identifier of the review that was scored */ + private java.lang.CharSequence reviewId; + /** Normalized relevance score [0.0, 1.0], null if scoring failed */ + private java.lang.Double score; + /** Version of the semantic model used (e.g., 'all-MiniLM-L6-v2@3.0.0') */ + private java.lang.CharSequence modelVersion; + /** Raw cosine similarity [-1.0, 1.0] before normalization (debug info) */ + private java.lang.Double rawCosineSimilarity; + /** Confidence score [0.0, 1.0] based on text quality and model certainty */ + private java.lang.Double confidence; + /** Error code if scoring failed (e.g., 'EMPTY_REVIEW', 'GIBBERISH_TEXT') */ + private java.lang.CharSequence errorCode; + /** Descriptive error message if scoring failed */ + private java.lang.CharSequence errorMessage; + /** Unix timestamp (milliseconds) when scoring completed */ + private long timestamp; + /** Time taken to score the review in milliseconds */ + private long processingTimeMs; + + /** + * Default constructor. Note that this does not initialize fields + * to their default values from the schema. If that is desired then + * one should use newBuilder(). + */ + public ReviewScoringResult() {} + + /** + * All-args constructor. + * @param reviewId Unique identifier of the review that was scored + * @param score Normalized relevance score [0.0, 1.0], null if scoring failed + * @param modelVersion Version of the semantic model used (e.g., 'all-MiniLM-L6-v2@3.0.0') + * @param rawCosineSimilarity Raw cosine similarity [-1.0, 1.0] before normalization (debug info) + * @param confidence Confidence score [0.0, 1.0] based on text quality and model certainty + * @param errorCode Error code if scoring failed (e.g., 'EMPTY_REVIEW', 'GIBBERISH_TEXT') + * @param errorMessage Descriptive error message if scoring failed + * @param timestamp Unix timestamp (milliseconds) when scoring completed + * @param processingTimeMs Time taken to score the review in milliseconds + */ + public ReviewScoringResult(java.lang.CharSequence reviewId, java.lang.Double score, java.lang.CharSequence modelVersion, java.lang.Double rawCosineSimilarity, java.lang.Double confidence, java.lang.CharSequence errorCode, java.lang.CharSequence errorMessage, java.lang.Long timestamp, java.lang.Long processingTimeMs) { + this.reviewId = reviewId; + this.score = score; + this.modelVersion = modelVersion; + this.rawCosineSimilarity = rawCosineSimilarity; + this.confidence = confidence; + this.errorCode = errorCode; + this.errorMessage = errorMessage; + this.timestamp = timestamp; + this.processingTimeMs = processingTimeMs; + } + + @Override + public org.apache.avro.specific.SpecificData getSpecificData() { return MODEL$; } + + @Override + public org.apache.avro.Schema getSchema() { return SCHEMA$; } + + // Used by DatumWriter. Applications should not call. + @Override + public java.lang.Object get(int field$) { + switch (field$) { + case 0: return reviewId; + case 1: return score; + case 2: return modelVersion; + case 3: return rawCosineSimilarity; + case 4: return confidence; + case 5: return errorCode; + case 6: return errorMessage; + case 7: return timestamp; + case 8: return processingTimeMs; + default: throw new IndexOutOfBoundsException("Invalid index: " + field$); + } + } + + // Used by DatumReader. Applications should not call. + @Override + @SuppressWarnings(value="unchecked") + public void put(int field$, java.lang.Object value$) { + switch (field$) { + case 0: reviewId = (java.lang.CharSequence)value$; break; + case 1: score = (java.lang.Double)value$; break; + case 2: modelVersion = (java.lang.CharSequence)value$; break; + case 3: rawCosineSimilarity = (java.lang.Double)value$; break; + case 4: confidence = (java.lang.Double)value$; break; + case 5: errorCode = (java.lang.CharSequence)value$; break; + case 6: errorMessage = (java.lang.CharSequence)value$; break; + case 7: timestamp = (java.lang.Long)value$; break; + case 8: processingTimeMs = (java.lang.Long)value$; break; + default: throw new IndexOutOfBoundsException("Invalid index: " + field$); + } + } + + /** + * Gets the value of the 'reviewId' field. + * @return Unique identifier of the review that was scored + */ + public java.lang.CharSequence getReviewId() { + return reviewId; + } + + + /** + * Sets the value of the 'reviewId' field. + * Unique identifier of the review that was scored + * @param value the value to set. + */ + public void setReviewId(java.lang.CharSequence value) { + this.reviewId = value; + } + + /** + * Gets the value of the 'score' field. + * @return Normalized relevance score [0.0, 1.0], null if scoring failed + */ + public java.lang.Double getScore() { + return score; + } + + + /** + * Sets the value of the 'score' field. + * Normalized relevance score [0.0, 1.0], null if scoring failed + * @param value the value to set. + */ + public void setScore(java.lang.Double value) { + this.score = value; + } + + /** + * Gets the value of the 'modelVersion' field. + * @return Version of the semantic model used (e.g., 'all-MiniLM-L6-v2@3.0.0') + */ + public java.lang.CharSequence getModelVersion() { + return modelVersion; + } + + + /** + * Sets the value of the 'modelVersion' field. + * Version of the semantic model used (e.g., 'all-MiniLM-L6-v2@3.0.0') + * @param value the value to set. + */ + public void setModelVersion(java.lang.CharSequence value) { + this.modelVersion = value; + } + + /** + * Gets the value of the 'rawCosineSimilarity' field. + * @return Raw cosine similarity [-1.0, 1.0] before normalization (debug info) + */ + public java.lang.Double getRawCosineSimilarity() { + return rawCosineSimilarity; + } + + + /** + * Sets the value of the 'rawCosineSimilarity' field. + * Raw cosine similarity [-1.0, 1.0] before normalization (debug info) + * @param value the value to set. + */ + public void setRawCosineSimilarity(java.lang.Double value) { + this.rawCosineSimilarity = value; + } + + /** + * Gets the value of the 'confidence' field. + * @return Confidence score [0.0, 1.0] based on text quality and model certainty + */ + public java.lang.Double getConfidence() { + return confidence; + } + + + /** + * Sets the value of the 'confidence' field. + * Confidence score [0.0, 1.0] based on text quality and model certainty + * @param value the value to set. + */ + public void setConfidence(java.lang.Double value) { + this.confidence = value; + } + + /** + * Gets the value of the 'errorCode' field. + * @return Error code if scoring failed (e.g., 'EMPTY_REVIEW', 'GIBBERISH_TEXT') + */ + public java.lang.CharSequence getErrorCode() { + return errorCode; + } + + + /** + * Sets the value of the 'errorCode' field. + * Error code if scoring failed (e.g., 'EMPTY_REVIEW', 'GIBBERISH_TEXT') + * @param value the value to set. + */ + public void setErrorCode(java.lang.CharSequence value) { + this.errorCode = value; + } + + /** + * Gets the value of the 'errorMessage' field. + * @return Descriptive error message if scoring failed + */ + public java.lang.CharSequence getErrorMessage() { + return errorMessage; + } + + + /** + * Sets the value of the 'errorMessage' field. + * Descriptive error message if scoring failed + * @param value the value to set. + */ + public void setErrorMessage(java.lang.CharSequence value) { + this.errorMessage = value; + } + + /** + * Gets the value of the 'timestamp' field. + * @return Unix timestamp (milliseconds) when scoring completed + */ + public long getTimestamp() { + return timestamp; + } + + + /** + * Sets the value of the 'timestamp' field. + * Unix timestamp (milliseconds) when scoring completed + * @param value the value to set. + */ + public void setTimestamp(long value) { + this.timestamp = value; + } + + /** + * Gets the value of the 'processingTimeMs' field. + * @return Time taken to score the review in milliseconds + */ + public long getProcessingTimeMs() { + return processingTimeMs; + } + + + /** + * Sets the value of the 'processingTimeMs' field. + * Time taken to score the review in milliseconds + * @param value the value to set. + */ + public void setProcessingTimeMs(long value) { + this.processingTimeMs = value; + } + + /** + * Creates a new ReviewScoringResult RecordBuilder. + * @return A new ReviewScoringResult RecordBuilder + */ + public static org.library.reviewService.event.ReviewScoringResult.Builder newBuilder() { + return new org.library.reviewService.event.ReviewScoringResult.Builder(); + } + + /** + * Creates a new ReviewScoringResult RecordBuilder by copying an existing Builder. + * @param other The existing builder to copy. + * @return A new ReviewScoringResult RecordBuilder + */ + public static org.library.reviewService.event.ReviewScoringResult.Builder newBuilder(org.library.reviewService.event.ReviewScoringResult.Builder other) { + if (other == null) { + return new org.library.reviewService.event.ReviewScoringResult.Builder(); + } else { + return new org.library.reviewService.event.ReviewScoringResult.Builder(other); + } + } + + /** + * Creates a new ReviewScoringResult RecordBuilder by copying an existing ReviewScoringResult instance. + * @param other The existing instance to copy. + * @return A new ReviewScoringResult RecordBuilder + */ + public static org.library.reviewService.event.ReviewScoringResult.Builder newBuilder(org.library.reviewService.event.ReviewScoringResult other) { + if (other == null) { + return new org.library.reviewService.event.ReviewScoringResult.Builder(); + } else { + return new org.library.reviewService.event.ReviewScoringResult.Builder(other); + } + } + + /** + * RecordBuilder for ReviewScoringResult instances. + */ + @org.apache.avro.specific.AvroGenerated + public static class Builder extends org.apache.avro.specific.SpecificRecordBuilderBase + implements org.apache.avro.data.RecordBuilder { + + /** Unique identifier of the review that was scored */ + private java.lang.CharSequence reviewId; + /** Normalized relevance score [0.0, 1.0], null if scoring failed */ + private java.lang.Double score; + /** Version of the semantic model used (e.g., 'all-MiniLM-L6-v2@3.0.0') */ + private java.lang.CharSequence modelVersion; + /** Raw cosine similarity [-1.0, 1.0] before normalization (debug info) */ + private java.lang.Double rawCosineSimilarity; + /** Confidence score [0.0, 1.0] based on text quality and model certainty */ + private java.lang.Double confidence; + /** Error code if scoring failed (e.g., 'EMPTY_REVIEW', 'GIBBERISH_TEXT') */ + private java.lang.CharSequence errorCode; + /** Descriptive error message if scoring failed */ + private java.lang.CharSequence errorMessage; + /** Unix timestamp (milliseconds) when scoring completed */ + private long timestamp; + /** Time taken to score the review in milliseconds */ + private long processingTimeMs; + + /** Creates a new Builder */ + private Builder() { + super(SCHEMA$, MODEL$); + } + + /** + * Creates a Builder by copying an existing Builder. + * @param other The existing Builder to copy. + */ + private Builder(org.library.reviewService.event.ReviewScoringResult.Builder other) { + super(other); + if (isValidValue(fields()[0], other.reviewId)) { + this.reviewId = data().deepCopy(fields()[0].schema(), other.reviewId); + fieldSetFlags()[0] = other.fieldSetFlags()[0]; + } + if (isValidValue(fields()[1], other.score)) { + this.score = data().deepCopy(fields()[1].schema(), other.score); + fieldSetFlags()[1] = other.fieldSetFlags()[1]; + } + if (isValidValue(fields()[2], other.modelVersion)) { + this.modelVersion = data().deepCopy(fields()[2].schema(), other.modelVersion); + fieldSetFlags()[2] = other.fieldSetFlags()[2]; + } + if (isValidValue(fields()[3], other.rawCosineSimilarity)) { + this.rawCosineSimilarity = data().deepCopy(fields()[3].schema(), other.rawCosineSimilarity); + fieldSetFlags()[3] = other.fieldSetFlags()[3]; + } + if (isValidValue(fields()[4], other.confidence)) { + this.confidence = data().deepCopy(fields()[4].schema(), other.confidence); + fieldSetFlags()[4] = other.fieldSetFlags()[4]; + } + if (isValidValue(fields()[5], other.errorCode)) { + this.errorCode = data().deepCopy(fields()[5].schema(), other.errorCode); + fieldSetFlags()[5] = other.fieldSetFlags()[5]; + } + if (isValidValue(fields()[6], other.errorMessage)) { + this.errorMessage = data().deepCopy(fields()[6].schema(), other.errorMessage); + fieldSetFlags()[6] = other.fieldSetFlags()[6]; + } + if (isValidValue(fields()[7], other.timestamp)) { + this.timestamp = data().deepCopy(fields()[7].schema(), other.timestamp); + fieldSetFlags()[7] = other.fieldSetFlags()[7]; + } + if (isValidValue(fields()[8], other.processingTimeMs)) { + this.processingTimeMs = data().deepCopy(fields()[8].schema(), other.processingTimeMs); + fieldSetFlags()[8] = other.fieldSetFlags()[8]; + } + } + + /** + * Creates a Builder by copying an existing ReviewScoringResult instance + * @param other The existing instance to copy. + */ + private Builder(org.library.reviewService.event.ReviewScoringResult other) { + super(SCHEMA$, MODEL$); + if (isValidValue(fields()[0], other.reviewId)) { + this.reviewId = data().deepCopy(fields()[0].schema(), other.reviewId); + fieldSetFlags()[0] = true; + } + if (isValidValue(fields()[1], other.score)) { + this.score = data().deepCopy(fields()[1].schema(), other.score); + fieldSetFlags()[1] = true; + } + if (isValidValue(fields()[2], other.modelVersion)) { + this.modelVersion = data().deepCopy(fields()[2].schema(), other.modelVersion); + fieldSetFlags()[2] = true; + } + if (isValidValue(fields()[3], other.rawCosineSimilarity)) { + this.rawCosineSimilarity = data().deepCopy(fields()[3].schema(), other.rawCosineSimilarity); + fieldSetFlags()[3] = true; + } + if (isValidValue(fields()[4], other.confidence)) { + this.confidence = data().deepCopy(fields()[4].schema(), other.confidence); + fieldSetFlags()[4] = true; + } + if (isValidValue(fields()[5], other.errorCode)) { + this.errorCode = data().deepCopy(fields()[5].schema(), other.errorCode); + fieldSetFlags()[5] = true; + } + if (isValidValue(fields()[6], other.errorMessage)) { + this.errorMessage = data().deepCopy(fields()[6].schema(), other.errorMessage); + fieldSetFlags()[6] = true; + } + if (isValidValue(fields()[7], other.timestamp)) { + this.timestamp = data().deepCopy(fields()[7].schema(), other.timestamp); + fieldSetFlags()[7] = true; + } + if (isValidValue(fields()[8], other.processingTimeMs)) { + this.processingTimeMs = data().deepCopy(fields()[8].schema(), other.processingTimeMs); + fieldSetFlags()[8] = true; + } + } + + /** + * Gets the value of the 'reviewId' field. + * Unique identifier of the review that was scored + * @return The value. + */ + public java.lang.CharSequence getReviewId() { + return reviewId; + } + + + /** + * Sets the value of the 'reviewId' field. + * Unique identifier of the review that was scored + * @param value The value of 'reviewId'. + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringResult.Builder setReviewId(java.lang.CharSequence value) { + validate(fields()[0], value); + this.reviewId = value; + fieldSetFlags()[0] = true; + return this; + } + + /** + * Checks whether the 'reviewId' field has been set. + * Unique identifier of the review that was scored + * @return True if the 'reviewId' field has been set, false otherwise. + */ + public boolean hasReviewId() { + return fieldSetFlags()[0]; + } + + + /** + * Clears the value of the 'reviewId' field. + * Unique identifier of the review that was scored + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringResult.Builder clearReviewId() { + reviewId = null; + fieldSetFlags()[0] = false; + return this; + } + + /** + * Gets the value of the 'score' field. + * Normalized relevance score [0.0, 1.0], null if scoring failed + * @return The value. + */ + public java.lang.Double getScore() { + return score; + } + + + /** + * Sets the value of the 'score' field. + * Normalized relevance score [0.0, 1.0], null if scoring failed + * @param value The value of 'score'. + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringResult.Builder setScore(java.lang.Double value) { + validate(fields()[1], value); + this.score = value; + fieldSetFlags()[1] = true; + return this; + } + + /** + * Checks whether the 'score' field has been set. + * Normalized relevance score [0.0, 1.0], null if scoring failed + * @return True if the 'score' field has been set, false otherwise. + */ + public boolean hasScore() { + return fieldSetFlags()[1]; + } + + + /** + * Clears the value of the 'score' field. + * Normalized relevance score [0.0, 1.0], null if scoring failed + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringResult.Builder clearScore() { + score = null; + fieldSetFlags()[1] = false; + return this; + } + + /** + * Gets the value of the 'modelVersion' field. + * Version of the semantic model used (e.g., 'all-MiniLM-L6-v2@3.0.0') + * @return The value. + */ + public java.lang.CharSequence getModelVersion() { + return modelVersion; + } + + + /** + * Sets the value of the 'modelVersion' field. + * Version of the semantic model used (e.g., 'all-MiniLM-L6-v2@3.0.0') + * @param value The value of 'modelVersion'. + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringResult.Builder setModelVersion(java.lang.CharSequence value) { + validate(fields()[2], value); + this.modelVersion = value; + fieldSetFlags()[2] = true; + return this; + } + + /** + * Checks whether the 'modelVersion' field has been set. + * Version of the semantic model used (e.g., 'all-MiniLM-L6-v2@3.0.0') + * @return True if the 'modelVersion' field has been set, false otherwise. + */ + public boolean hasModelVersion() { + return fieldSetFlags()[2]; + } + + + /** + * Clears the value of the 'modelVersion' field. + * Version of the semantic model used (e.g., 'all-MiniLM-L6-v2@3.0.0') + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringResult.Builder clearModelVersion() { + modelVersion = null; + fieldSetFlags()[2] = false; + return this; + } + + /** + * Gets the value of the 'rawCosineSimilarity' field. + * Raw cosine similarity [-1.0, 1.0] before normalization (debug info) + * @return The value. + */ + public java.lang.Double getRawCosineSimilarity() { + return rawCosineSimilarity; + } + + + /** + * Sets the value of the 'rawCosineSimilarity' field. + * Raw cosine similarity [-1.0, 1.0] before normalization (debug info) + * @param value The value of 'rawCosineSimilarity'. + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringResult.Builder setRawCosineSimilarity(java.lang.Double value) { + validate(fields()[3], value); + this.rawCosineSimilarity = value; + fieldSetFlags()[3] = true; + return this; + } + + /** + * Checks whether the 'rawCosineSimilarity' field has been set. + * Raw cosine similarity [-1.0, 1.0] before normalization (debug info) + * @return True if the 'rawCosineSimilarity' field has been set, false otherwise. + */ + public boolean hasRawCosineSimilarity() { + return fieldSetFlags()[3]; + } + + + /** + * Clears the value of the 'rawCosineSimilarity' field. + * Raw cosine similarity [-1.0, 1.0] before normalization (debug info) + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringResult.Builder clearRawCosineSimilarity() { + rawCosineSimilarity = null; + fieldSetFlags()[3] = false; + return this; + } + + /** + * Gets the value of the 'confidence' field. + * Confidence score [0.0, 1.0] based on text quality and model certainty + * @return The value. + */ + public java.lang.Double getConfidence() { + return confidence; + } + + + /** + * Sets the value of the 'confidence' field. + * Confidence score [0.0, 1.0] based on text quality and model certainty + * @param value The value of 'confidence'. + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringResult.Builder setConfidence(java.lang.Double value) { + validate(fields()[4], value); + this.confidence = value; + fieldSetFlags()[4] = true; + return this; + } + + /** + * Checks whether the 'confidence' field has been set. + * Confidence score [0.0, 1.0] based on text quality and model certainty + * @return True if the 'confidence' field has been set, false otherwise. + */ + public boolean hasConfidence() { + return fieldSetFlags()[4]; + } + + + /** + * Clears the value of the 'confidence' field. + * Confidence score [0.0, 1.0] based on text quality and model certainty + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringResult.Builder clearConfidence() { + confidence = null; + fieldSetFlags()[4] = false; + return this; + } + + /** + * Gets the value of the 'errorCode' field. + * Error code if scoring failed (e.g., 'EMPTY_REVIEW', 'GIBBERISH_TEXT') + * @return The value. + */ + public java.lang.CharSequence getErrorCode() { + return errorCode; + } + + + /** + * Sets the value of the 'errorCode' field. + * Error code if scoring failed (e.g., 'EMPTY_REVIEW', 'GIBBERISH_TEXT') + * @param value The value of 'errorCode'. + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringResult.Builder setErrorCode(java.lang.CharSequence value) { + validate(fields()[5], value); + this.errorCode = value; + fieldSetFlags()[5] = true; + return this; + } + + /** + * Checks whether the 'errorCode' field has been set. + * Error code if scoring failed (e.g., 'EMPTY_REVIEW', 'GIBBERISH_TEXT') + * @return True if the 'errorCode' field has been set, false otherwise. + */ + public boolean hasErrorCode() { + return fieldSetFlags()[5]; + } + + + /** + * Clears the value of the 'errorCode' field. + * Error code if scoring failed (e.g., 'EMPTY_REVIEW', 'GIBBERISH_TEXT') + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringResult.Builder clearErrorCode() { + errorCode = null; + fieldSetFlags()[5] = false; + return this; + } + + /** + * Gets the value of the 'errorMessage' field. + * Descriptive error message if scoring failed + * @return The value. + */ + public java.lang.CharSequence getErrorMessage() { + return errorMessage; + } + + + /** + * Sets the value of the 'errorMessage' field. + * Descriptive error message if scoring failed + * @param value The value of 'errorMessage'. + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringResult.Builder setErrorMessage(java.lang.CharSequence value) { + validate(fields()[6], value); + this.errorMessage = value; + fieldSetFlags()[6] = true; + return this; + } + + /** + * Checks whether the 'errorMessage' field has been set. + * Descriptive error message if scoring failed + * @return True if the 'errorMessage' field has been set, false otherwise. + */ + public boolean hasErrorMessage() { + return fieldSetFlags()[6]; + } + + + /** + * Clears the value of the 'errorMessage' field. + * Descriptive error message if scoring failed + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringResult.Builder clearErrorMessage() { + errorMessage = null; + fieldSetFlags()[6] = false; + return this; + } + + /** + * Gets the value of the 'timestamp' field. + * Unix timestamp (milliseconds) when scoring completed + * @return The value. + */ + public long getTimestamp() { + return timestamp; + } + + + /** + * Sets the value of the 'timestamp' field. + * Unix timestamp (milliseconds) when scoring completed + * @param value The value of 'timestamp'. + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringResult.Builder setTimestamp(long value) { + validate(fields()[7], value); + this.timestamp = value; + fieldSetFlags()[7] = true; + return this; + } + + /** + * Checks whether the 'timestamp' field has been set. + * Unix timestamp (milliseconds) when scoring completed + * @return True if the 'timestamp' field has been set, false otherwise. + */ + public boolean hasTimestamp() { + return fieldSetFlags()[7]; + } + + + /** + * Clears the value of the 'timestamp' field. + * Unix timestamp (milliseconds) when scoring completed + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringResult.Builder clearTimestamp() { + fieldSetFlags()[7] = false; + return this; + } + + /** + * Gets the value of the 'processingTimeMs' field. + * Time taken to score the review in milliseconds + * @return The value. + */ + public long getProcessingTimeMs() { + return processingTimeMs; + } + + + /** + * Sets the value of the 'processingTimeMs' field. + * Time taken to score the review in milliseconds + * @param value The value of 'processingTimeMs'. + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringResult.Builder setProcessingTimeMs(long value) { + validate(fields()[8], value); + this.processingTimeMs = value; + fieldSetFlags()[8] = true; + return this; + } + + /** + * Checks whether the 'processingTimeMs' field has been set. + * Time taken to score the review in milliseconds + * @return True if the 'processingTimeMs' field has been set, false otherwise. + */ + public boolean hasProcessingTimeMs() { + return fieldSetFlags()[8]; + } + + + /** + * Clears the value of the 'processingTimeMs' field. + * Time taken to score the review in milliseconds + * @return This builder. + */ + public org.library.reviewService.event.ReviewScoringResult.Builder clearProcessingTimeMs() { + fieldSetFlags()[8] = false; + return this; + } + + @Override + @SuppressWarnings("unchecked") + public ReviewScoringResult build() { + try { + ReviewScoringResult record = new ReviewScoringResult(); + record.reviewId = fieldSetFlags()[0] ? this.reviewId : (java.lang.CharSequence) defaultValue(fields()[0]); + record.score = fieldSetFlags()[1] ? this.score : (java.lang.Double) defaultValue(fields()[1]); + record.modelVersion = fieldSetFlags()[2] ? this.modelVersion : (java.lang.CharSequence) defaultValue(fields()[2]); + record.rawCosineSimilarity = fieldSetFlags()[3] ? this.rawCosineSimilarity : (java.lang.Double) defaultValue(fields()[3]); + record.confidence = fieldSetFlags()[4] ? this.confidence : (java.lang.Double) defaultValue(fields()[4]); + record.errorCode = fieldSetFlags()[5] ? this.errorCode : (java.lang.CharSequence) defaultValue(fields()[5]); + record.errorMessage = fieldSetFlags()[6] ? this.errorMessage : (java.lang.CharSequence) defaultValue(fields()[6]); + record.timestamp = fieldSetFlags()[7] ? this.timestamp : (java.lang.Long) defaultValue(fields()[7]); + record.processingTimeMs = fieldSetFlags()[8] ? this.processingTimeMs : (java.lang.Long) defaultValue(fields()[8]); + return record; + } catch (org.apache.avro.AvroMissingFieldException e) { + throw e; + } catch (java.lang.Exception e) { + throw new org.apache.avro.AvroRuntimeException(e); + } + } + } + + @SuppressWarnings("unchecked") + private static final org.apache.avro.io.DatumWriter + WRITER$ = (org.apache.avro.io.DatumWriter)MODEL$.createDatumWriter(SCHEMA$); + + @Override public void writeExternal(java.io.ObjectOutput out) + throws java.io.IOException { + WRITER$.write(this, SpecificData.getEncoder(out)); + } + + @SuppressWarnings("unchecked") + private static final org.apache.avro.io.DatumReader + READER$ = (org.apache.avro.io.DatumReader)MODEL$.createDatumReader(SCHEMA$); + + @Override public void readExternal(java.io.ObjectInput in) + throws java.io.IOException { + READER$.read(this, SpecificData.getDecoder(in)); + } + + @Override protected boolean hasCustomCoders() { return true; } + + @Override public void customEncode(org.apache.avro.io.Encoder out) + throws java.io.IOException + { + out.writeString(this.reviewId); + + if (this.score == null) { + out.writeIndex(0); + out.writeNull(); + } else { + out.writeIndex(1); + out.writeDouble(this.score); + } + + out.writeString(this.modelVersion); + + if (this.rawCosineSimilarity == null) { + out.writeIndex(0); + out.writeNull(); + } else { + out.writeIndex(1); + out.writeDouble(this.rawCosineSimilarity); + } + + if (this.confidence == null) { + out.writeIndex(0); + out.writeNull(); + } else { + out.writeIndex(1); + out.writeDouble(this.confidence); + } + + if (this.errorCode == null) { + out.writeIndex(0); + out.writeNull(); + } else { + out.writeIndex(1); + out.writeString(this.errorCode); + } + + if (this.errorMessage == null) { + out.writeIndex(0); + out.writeNull(); + } else { + out.writeIndex(1); + out.writeString(this.errorMessage); + } + + out.writeLong(this.timestamp); + + out.writeLong(this.processingTimeMs); + + } + + @Override public void customDecode(org.apache.avro.io.ResolvingDecoder in) + throws java.io.IOException + { + org.apache.avro.Schema.Field[] fieldOrder = in.readFieldOrderIfDiff(); + if (fieldOrder == null) { + this.reviewId = in.readString(this.reviewId instanceof Utf8 ? (Utf8)this.reviewId : null); + + if (in.readIndex() != 1) { + in.readNull(); + this.score = null; + } else { + this.score = in.readDouble(); + } + + this.modelVersion = in.readString(this.modelVersion instanceof Utf8 ? (Utf8)this.modelVersion : null); + + if (in.readIndex() != 1) { + in.readNull(); + this.rawCosineSimilarity = null; + } else { + this.rawCosineSimilarity = in.readDouble(); + } + + if (in.readIndex() != 1) { + in.readNull(); + this.confidence = null; + } else { + this.confidence = in.readDouble(); + } + + if (in.readIndex() != 1) { + in.readNull(); + this.errorCode = null; + } else { + this.errorCode = in.readString(this.errorCode instanceof Utf8 ? (Utf8)this.errorCode : null); + } + + if (in.readIndex() != 1) { + in.readNull(); + this.errorMessage = null; + } else { + this.errorMessage = in.readString(this.errorMessage instanceof Utf8 ? (Utf8)this.errorMessage : null); + } + + this.timestamp = in.readLong(); + + this.processingTimeMs = in.readLong(); + + } else { + for (int i = 0; i < 9; i++) { + switch (fieldOrder[i].pos()) { + case 0: + this.reviewId = in.readString(this.reviewId instanceof Utf8 ? (Utf8)this.reviewId : null); + break; + + case 1: + if (in.readIndex() != 1) { + in.readNull(); + this.score = null; + } else { + this.score = in.readDouble(); + } + break; + + case 2: + this.modelVersion = in.readString(this.modelVersion instanceof Utf8 ? (Utf8)this.modelVersion : null); + break; + + case 3: + if (in.readIndex() != 1) { + in.readNull(); + this.rawCosineSimilarity = null; + } else { + this.rawCosineSimilarity = in.readDouble(); + } + break; + + case 4: + if (in.readIndex() != 1) { + in.readNull(); + this.confidence = null; + } else { + this.confidence = in.readDouble(); + } + break; + + case 5: + if (in.readIndex() != 1) { + in.readNull(); + this.errorCode = null; + } else { + this.errorCode = in.readString(this.errorCode instanceof Utf8 ? (Utf8)this.errorCode : null); + } + break; + + case 6: + if (in.readIndex() != 1) { + in.readNull(); + this.errorMessage = null; + } else { + this.errorMessage = in.readString(this.errorMessage instanceof Utf8 ? (Utf8)this.errorMessage : null); + } + break; + + case 7: + this.timestamp = in.readLong(); + break; + + case 8: + this.processingTimeMs = in.readLong(); + break; + + default: + throw new java.io.IOException("Corrupt ResolvingDecoder."); + } + } + } + } +} + + + + + + + + + + diff --git a/reviewService/src/main/java/org/library/reviewService/integration/consumer/ReviewScoringResultListener.java b/reviewService/src/main/java/org/library/reviewService/integration/consumer/ReviewScoringResultListener.java new file mode 100644 index 0000000..b1c2ef4 --- /dev/null +++ b/reviewService/src/main/java/org/library/reviewService/integration/consumer/ReviewScoringResultListener.java @@ -0,0 +1,120 @@ +package org.library.reviewService.integration.consumer; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.library.reviewService.event.ReviewScoringResult; +import org.library.reviewService.model.Review; +import org.library.reviewService.repository.ReviewRepository; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +/** + * Kafka consumer listener for review scoring results. + * Listens to review-scoring-results topic and updates MongoDB with the computed scores. + * Provides visibility into asynchronous scoring completion and handles errors gracefully. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ReviewScoringResultListener { + + private final ReviewRepository reviewRepository; + + /** + * Listens for ReviewScoringResult events from the recommender service. + * Updates the MongoDB review document with the scoring result, including: + * - Final relevance score [0.0, 1.0] + * - Model version used + * - Confidence score + * - Processing time + * - Error information (if scoring failed) + * + * @param result The ReviewScoringResult event from Kafka + */ + @KafkaListener( + topics = "${kafka.topics.review-scoring-result:review-scoring-results}", + groupId = "${spring.kafka.consumer.group-id:review-service-group}", + containerFactory = "kafkaListenerContainerFactory" + ) + public void onScoringResult(ReviewScoringResult result) { + try { + if (result == null || result.getReviewId() == null) { + log.warn("Received null or invalid ReviewScoringResult"); + return; + } + + // Convert Avro Utf8 to String for review ID + String reviewId = result.getReviewId().toString(); + + log.info("Received scoring result for review={} score={} errorCode={}", + reviewId, + result.getScore(), + result.getErrorCode()); + + // Find and update the review document + reviewRepository.findById(reviewId).ifPresentOrElse( + review -> updateReviewWithScoringResult(review, result), + () -> log.error("Review not found for id={}", reviewId) + ); + + } catch (Exception e) { + log.error("Error processing scoring result for review={}: {}", + result != null ? result.getReviewId() : "unknown", + e.getMessage(), e); + } + } + + /** + * Updates a Review document with the scoring result. + * Creates a ReviewScoringResult embedded document and saves to MongoDB. + * + * @param review The review entity to update + * @param result The scoring result from Kafka + */ + private void updateReviewWithScoringResult(Review review, ReviewScoringResult result) { + try { + // Convert Avro Utf8 strings to Java String using toString() + // (Avro deserializer returns org.apache.avro.util.Utf8 for string fields) + String modelVersion = result.getModelVersion() != null ? result.getModelVersion().toString() : null; + String errorCode = result.getErrorCode() != null ? result.getErrorCode().toString() : null; + String errorMessage = result.getErrorMessage() != null ? result.getErrorMessage().toString() : null; + + // Create ReviewScoringResult embedded document + org.library.reviewService.model.ReviewScoringResult scoringResult = + org.library.reviewService.model.ReviewScoringResult.builder() + .score(result.getScore()) + .modelVersion(modelVersion) + .rawCosineSimilarity(result.getRawCosineSimilarity()) + .confidence(result.getConfidence()) + .errorCode(errorCode) + .errorMessage(errorMessage) + .timestamp(result.getTimestamp()) + .processingTimeMs(result.getProcessingTimeMs()) + .build(); + + // Update review with scoring result + review.setScoringResult(scoringResult); + + // Save to MongoDB + reviewRepository.save(review); + + // Log result + if (scoringResult.isSuccessful()) { + log.info("Successfully updated review={} with score={:.3f} confidence={:.3f} processingTimeMs={}", + review.getId(), + result.getScore(), + result.getConfidence(), + result.getProcessingTimeMs()); + } else { + log.warn("Scoring failed for review={} errorCode={} errorMessage={}", + review.getId(), + result.getErrorCode(), + result.getErrorMessage()); + } + + } catch (Exception e) { + log.error("Error updating review {} with scoring result: {}", + review.getId(), e.getMessage(), e); + } + } +} diff --git a/reviewService/src/main/java/org/library/reviewService/integration/producer/ReviewScoringRequestProducer.java b/reviewService/src/main/java/org/library/reviewService/integration/producer/ReviewScoringRequestProducer.java new file mode 100644 index 0000000..2b24061 --- /dev/null +++ b/reviewService/src/main/java/org/library/reviewService/integration/producer/ReviewScoringRequestProducer.java @@ -0,0 +1,59 @@ +package org.library.reviewService.integration.producer; + + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.library.reviewService.event.ReviewScoringRequest; +import org.library.reviewService.model.Review; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; +import java.util.UUID; + +/** + * Kafka producer for publishing review scoring requests to the recommender service. + * Publishes ReviewScoringRequest events when a new review is created, triggering + * asynchronous semantic relevance scoring via the sentence transformer model. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ReviewScoringRequestProducer { + + private final KafkaTemplate kafkaTemplate; + + @Value("${kafka.topics.review-scoring-request:review-scoring-request}") + private String reviewScoringRequestTopic; + + /** + * Publishes a review scoring request event when a new review is created. + * The recommender service will consume this event, compute semantic relevance, + * and publish a ReviewScoringResult back to the review-scoring-result topic. + * + * @param review The newly created review entity + * @param bookMetadata Aggregated metadata about the book (title, description, genres) + */ + public void publishReviewScoringRequest(Review review, String bookMetadata) { + try { + ReviewScoringRequest request = ReviewScoringRequest.newBuilder() + .setReviewId(review.getId()) + .setBookId(review.getBookId()) + .setReviewText(review.getText()) + .setBookMetadata(bookMetadata) + .setUserId(review.getUserId()) + .setTimestamp(System.currentTimeMillis()) + .setCorrelationId(UUID.randomUUID().toString()) + .build(); + + kafkaTemplate.send(reviewScoringRequestTopic, review.getId(), request); + + log.info("Published review scoring request for review={} book={} correlationId={}", + review.getId(), review.getBookId(), request.getCorrelationId()); + } catch (Exception e) { + log.error("Failed to publish review scoring request for review={}: {}", + review.getId(), e.getMessage(), e); + // Do not throw - allow review creation to proceed even if event publishing fails + // The review will remain unscored, and error handling/retry logic will manage recovery + } + } +} diff --git a/reviewService/src/main/java/org/library/reviewService/model/Review.java b/reviewService/src/main/java/org/library/reviewService/model/Review.java index 7e92a9a..7c02793 100644 --- a/reviewService/src/main/java/org/library/reviewService/model/Review.java +++ b/reviewService/src/main/java/org/library/reviewService/model/Review.java @@ -34,4 +34,11 @@ public class Review implements Identifiable, Archivable { private String text; private boolean archived; + + /** + * Semantic relevance score computed asynchronously by the recommender service. + * NULL until ReviewScoringResultListener processes the result event. + * Score range: [0.0, 1.0] where 1.0 = highly relevant, 0.0 = not relevant. + */ + private ReviewScoringResult scoringResult; } diff --git a/reviewService/src/main/java/org/library/reviewService/model/ReviewScoringResult.java b/reviewService/src/main/java/org/library/reviewService/model/ReviewScoringResult.java new file mode 100644 index 0000000..413c426 --- /dev/null +++ b/reviewService/src/main/java/org/library/reviewService/model/ReviewScoringResult.java @@ -0,0 +1,81 @@ +package org.library.reviewService.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.mongodb.core.mapping.Field; + +/** + * Embedded document for review relevance scoring result. + * Stores the score computed by the Python recommender service. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ReviewScoringResult { + + /** + * Relevance score (0.0 to 1.0). + * Higher values indicate the review is more semantically relevant to the book. + */ + @Field("score") + private Double score; + + /** + * Model version used for scoring (e.g., "all-MiniLM-L6-v2-v1.0"). + * Allows retroactive re-scoring when model is updated. + */ + @Field("model_version") + private String modelVersion; + + /** + * Raw cosine similarity before normalization [-1, 1]. + * Useful for debugging and ML monitoring. + */ + @Field("raw_cosine_similarity") + private Double rawCosineSimilarity; + + /** + * Confidence in the score (0.0 to 1.0). + * (|cosine_sim| ร— 0.7) + (embedding_quality ร— 0.3). + * Helps upstream decide fallback behavior if confidence is low. + */ + @Field("confidence") + private Double confidence; + + /** + * Error code if scoring failed (e.g., "NULL_REVIEW_TEXT", "INVALID_EMBEDDING"). + * NULL if successful. + */ + @Field("error_code") + private String errorCode; + + /** + * Error message if scoring failed. + * NULL if successful. + */ + @Field("error_message") + private String errorMessage; + + /** + * Unix timestamp when score was computed (milliseconds). + */ + @Field("timestamp") + private Long timestamp; + + /** + * Processing latency in milliseconds. + * Used for SLA monitoring and performance tracking. + */ + @Field("processing_time_ms") + private Long processingTimeMs; + + /** + * Whether scoring was successful (no error). + */ + public boolean isSuccessful() { + return errorCode == null && errorMessage == null && score != null; + } +} 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..c9b7cd9 100644 --- a/reviewService/src/main/java/org/library/reviewService/service/ReviewService.java +++ b/reviewService/src/main/java/org/library/reviewService/service/ReviewService.java @@ -1,31 +1,41 @@ package org.library.reviewService.service; import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; + import org.library.reviewService.client.BookServiceClient; +import org.library.reviewService.client.response.BookResponse; import org.library.reviewService.exception.AccessDeniedException; +import org.library.reviewService.integration.producer.ReviewScoringRequestProducer; import org.library.reviewService.model.Review; import org.library.reviewService.repository.BaseRepository; import org.library.reviewService.repository.ReviewRepository; +import org.library.reviewService.util.BookMetadataAggregator; import org.springframework.dao.DuplicateKeyException; import org.springframework.data.mongodb.core.MongoOperations; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.stereotype.Service; +import java.util.List; import java.util.NoSuchElementException; import java.util.Optional; @Service @AllArgsConstructor +@Slf4j public class ReviewService extends AbstractService { private final ReviewRepository reviewRepository; private final ReviewMetricsService metricsService; private final MongoOperations mongoOperations; private final BookServiceClient bookClient; + private final ReviewScoringRequestProducer reviewScoringRequestProducer; + private final BookMetadataAggregator bookMetadataAggregator; @Override protected BaseRepository getRepository() { @@ -103,8 +113,24 @@ public Review update(Review entity) { @Override protected void afterCreate(Review entity) { + // Update book rating metrics metricsService.addReviewMetrics(entity.getBookId(), entity.getRating()); + // Publish review scoring request event for semantic relevance scoring + try { + ResponseEntity bookResponse = bookClient.getById(entity.getBookId()); + if (bookResponse.hasBody()) { + // Aggregate metadata from nested objects (author, category, genres) + String bookMetadata = bookMetadataAggregator.aggregateMetadata( + (BookResponse) bookResponse.getBody()); + + reviewScoringRequestProducer.publishReviewScoringRequest(entity, bookMetadata); + } + } catch (Exception e) { + // Log error but don't fail review creation - scoring is async and non-critical + log.warn("Failed to trigger review scoring for review {}: {}", + entity.getId(), e.getMessage()); + } } @Override diff --git a/reviewService/src/main/java/org/library/reviewService/util/BookMetadataAggregator.java b/reviewService/src/main/java/org/library/reviewService/util/BookMetadataAggregator.java new file mode 100644 index 0000000..bdf0027 --- /dev/null +++ b/reviewService/src/main/java/org/library/reviewService/util/BookMetadataAggregator.java @@ -0,0 +1,107 @@ +package org.library.reviewService.util; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.library.reviewService.client.response.BookResponse; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Utility to aggregate book metadata for semantic relevance scoring. + * Follows Strategy C from design: selective fields aggregation. + * Produces 100-250 tokens for optimal embedding generation. + * + * Fields included: + * - Title (always) + * - Author name (if available) + * - Category name (if available) + * - Genres (if available) + * - Description (if available, highest semantic value) + * - ISBN (for disambiguity) + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class BookMetadataAggregator { + + private final ObjectMapper objectMapper; + + /** + * Aggregates book metadata fields into a JSON string for semantic embedding. + * Extracts from nested BookResponse objects (author, category, genres). + * + * Includes: title, author name, category name, genres, description to provide sufficient context + * for scoring review relevance to the book's content and themes. + * + * Follows Strategy C: selective fields with logical ordering for better token efficiency. + * + * @param book The BookResponse object from book service (with nested objects) + * @return JSON string containing aggregated metadata, or empty JSON on error + */ + public String aggregateMetadata(BookResponse book) { + try { + if (book == null) { + log.warn("BookResponse is null, returning empty metadata"); + return "{}"; + } + + Map metadata = new HashMap<>(); + + // Always include title (required field) + if (book.getTitle() != null && !book.getTitle().isBlank()) { + metadata.put("title", book.getTitle().trim()); + } + + // Extract and include author name from nested author object + if (book.getAuthor() != null && book.getAuthor().getName() != null + && !book.getAuthor().getName().isBlank()) { + metadata.put("author_name", book.getAuthor().getName().trim()); + } + + // Extract and include category name from nested category object + if (book.getCategory() != null && book.getCategory().getName() != null + && !book.getCategory().getName().isBlank()) { + metadata.put("category_name", book.getCategory().getName().trim()); + } + + // Extract and include genres as comma-separated string from list + if (book.getBookGenres() != null && !book.getBookGenres().isEmpty()) { + String genresStr = book.getBookGenres().stream() + .filter(g -> g != null && g.getName() != null) + .map(BookResponse.GenreResponse::getName) + .filter(name -> !name.isBlank()) + .collect(Collectors.joining(", ")); + + if (!genresStr.isBlank()) { + metadata.put("genres", genresStr); + } + } + + // Include description last (highest priority for semantic content) + // Description is the most information-rich field for relevance scoring + if (book.getDescription() != null && !book.getDescription().isBlank()) { + metadata.put("description", book.getDescription().trim()); + } + + // Include ISBN for disambiguity + if (book.getISBN() != null && !book.getISBN().isBlank()) { + metadata.put("isbn", book.getISBN().trim()); + } + + String result = objectMapper.writeValueAsString(metadata); + + log.debug("Aggregated book metadata for book id {}: {} fields, {} bytes", + book.getId(), metadata.size(), result.length()); + + return result; + } catch (Exception e) { + log.error("Error aggregating book metadata for book id {}: {}", + book != null ? book.getId() : "unknown", e.getMessage()); + return "{}"; + } + } +} diff --git a/reviewService/src/main/resources/application-dev.yml b/reviewService/src/main/resources/application-dev.yml index b93c315..cfbe8dc 100644 --- a/reviewService/src/main/resources/application-dev.yml +++ b/reviewService/src/main/resources/application-dev.yml @@ -14,16 +14,16 @@ spring: kafka: bootstrap-servers: localhost:9092 - consumer: - properties: - schema: - registry: - url: http://localhost:8082 - producer: - properties: - schema: - registry: - url: http://localhost:8082 + # Schema registry URL for local development (overrides default from application.properties) + properties: + schema: + registry: + url: http://localhost:8085 + +kafka: + topics: + review-scoring-request: review-scoring-requests + review-scoring-result: review-scoring-results book-service: url: http://localhost:8080 diff --git a/reviewService/src/main/resources/application-prod.yml b/reviewService/src/main/resources/application-prod.yml index f338c9e..6966160 100644 --- a/reviewService/src/main/resources/application-prod.yml +++ b/reviewService/src/main/resources/application-prod.yml @@ -7,6 +7,7 @@ spring: kafka: bootstrap-servers: broker:29092 consumer: + group-id: review-service-group properties: schema: registry: @@ -24,6 +25,11 @@ spring: issuer-uri: http://keycloak:8080/realms/e-library token-uri: http://keycloak:8080/realms/e-library/protocol/openid-connect/token +kafka: + topics: + review-scoring-request: review-scoring-requests + review-scoring-result: review-scoring-results + book-service: url: http://book-service:8080 gateway: diff --git a/reviewService/src/main/resources/avro/ReviewScoringRequest.avsc b/reviewService/src/main/resources/avro/ReviewScoringRequest.avsc new file mode 100644 index 0000000..874c9e9 --- /dev/null +++ b/reviewService/src/main/resources/avro/ReviewScoringRequest.avsc @@ -0,0 +1,43 @@ +{ + "type": "record", + "name": "ReviewScoringRequest", + "namespace": "org.library.reviewService.event", + "doc": "Request to score a book review for relevance using semantic similarity", + "fields": [ + { + "name": "reviewId", + "type": "string", + "doc": "Unique identifier of the review to be scored" + }, + { + "name": "bookId", + "type": "int", + "doc": "Unique identifier of the book being reviewed" + }, + { + "name": "reviewText", + "type": "string", + "doc": "The raw review text to be scored" + }, + { + "name": "bookMetadata", + "type": "string", + "doc": "Aggregated book metadata as JSON string (title, author, category, genres, description, ISBN) for semantic context" + }, + { + "name": "userId", + "type": "string", + "doc": "Unique identifier of the user who wrote the review" + }, + { + "name": "timestamp", + "type": "long", + "doc": "Unix timestamp (milliseconds) when review was created" + }, + { + "name": "correlationId", + "type": "string", + "doc": "Correlation ID for distributed tracing across services" + } + ] +} diff --git a/reviewService/src/main/resources/avro/ReviewScoringResult.avsc b/reviewService/src/main/resources/avro/ReviewScoringResult.avsc new file mode 100644 index 0000000..8c8231d --- /dev/null +++ b/reviewService/src/main/resources/avro/ReviewScoringResult.avsc @@ -0,0 +1,73 @@ +{ + "type": "record", + "name": "ReviewScoringResult", + "namespace": "org.library.reviewService.event", + "doc": "Result of semantic relevance scoring for a book review", + "fields": [ + { + "name": "reviewId", + "type": "string", + "doc": "Unique identifier of the review that was scored" + }, + { + "name": "score", + "type": [ + "null", + "double" + ], + "default": null, + "doc": "Normalized relevance score [0.0, 1.0], null if scoring failed" + }, + { + "name": "modelVersion", + "type": "string", + "doc": "Version of the semantic model used (e.g., 'all-MiniLM-L6-v2@3.0.0')" + }, + { + "name": "rawCosineSimilarity", + "type": [ + "null", + "double" + ], + "default": null, + "doc": "Raw cosine similarity [-1.0, 1.0] before normalization (debug info)" + }, + { + "name": "confidence", + "type": [ + "null", + "double" + ], + "default": null, + "doc": "Confidence score [0.0, 1.0] based on text quality and model certainty" + }, + { + "name": "errorCode", + "type": [ + "null", + "string" + ], + "default": null, + "doc": "Error code if scoring failed (e.g., 'EMPTY_REVIEW', 'GIBBERISH_TEXT')" + }, + { + "name": "errorMessage", + "type": [ + "null", + "string" + ], + "default": null, + "doc": "Descriptive error message if scoring failed" + }, + { + "name": "timestamp", + "type": "long", + "doc": "Unix timestamp (milliseconds) when scoring completed" + }, + { + "name": "processingTimeMs", + "type": "long", + "doc": "Time taken to score the review in milliseconds" + } + ] +} From 286a87f8cdb0b1d5468638a871e1dba97e7ceaf0 Mon Sep 17 00:00:00 2001 From: Maksym Diachuk Date: Fri, 24 Apr 2026 15:28:00 +0300 Subject: [PATCH 2/2] feat: add author pages, expose review relevance scores, and harden scoring pipeline - Add Authors list page and Author Details page with dedicated routes (/authors, /authors/:authorId) - Display top relevant reviews section on the home page, pulling review relevance scores from the scoring pipeline - Expose `relevanceScore` in ReviewResponse DTO and populate it via the mapper when a successful scoring result exists - Trigger re-scoring when a review's text is updated (not just on create) - Move Kafka topic name to config property in BookService (`kafka.topics.book-deleted`) instead of a hardcoded string - Add `bookId` / `bookTitle` / `bookAuthor` enrichment to reviews on the home page via forkJoin + BookService lookups - Seed initial data improvements in InitialDataGenerator - Add CLAUDE.md with full codebase documentation --- .claude/settings.json | 8 ++ .claude/settings.local.json | 12 ++ .gitignore | 8 +- CLAUDE.md | 87 +++++++++++ book-bazaar/src/app/app.routes.ts | 8 ++ .../author-details/author-details.css | 0 .../author-details/author-details.html | 90 ++++++++++++ .../author-details/author-details.ts | 117 +++++++++++++++ .../src/app/components/authors/authors.css | 0 .../src/app/components/authors/authors.html | 105 ++++++++++++++ .../src/app/components/authors/authors.ts | 135 ++++++++++++++++++ .../components/book-details/book-details.html | 62 ++++---- .../components/book-details/book-details.ts | 3 +- .../src/app/components/header/header.html | 5 + book-bazaar/src/app/components/home/home.html | 80 ++++++++++- book-bazaar/src/app/components/home/home.ts | 46 +++++- book-bazaar/src/app/model/review.ts | 1 + .../src/app/services/author/author-service.ts | 13 +- .../src/app/services/review/review-service.ts | 8 ++ .../bookservice/service/BookService.java | 13 +- .../listener/BookRatingUpdateListener.java | 2 +- .../src/main/resources/application-dev.yml | 5 + .../src/main/resources/application-prod.yml | 5 + .../src/main/resources/application.properties | 1 - .../core_scoring_strategies.cpython-313.pyc | Bin 0 -> 45871 bytes .../datagen/InitialDataGenerator.java | 31 +++- .../dto/review/ReviewResponse.java | 2 + .../consumer/BookDeletedListener.java | 2 +- .../producer/BookRatingUpdatedProducer.java | 2 +- .../reviewService/mapper/ReviewMapper.java | 5 + .../reviewService/service/ReviewService.java | 15 ++ .../src/main/resources/application-dev.yml | 7 + .../src/main/resources/application-prod.yml | 2 + .../src/main/resources/application.properties | 1 - 34 files changed, 834 insertions(+), 47 deletions(-) create mode 100644 .claude/settings.json create mode 100644 .claude/settings.local.json create mode 100644 CLAUDE.md create mode 100644 book-bazaar/src/app/components/author-details/author-details.css create mode 100644 book-bazaar/src/app/components/author-details/author-details.html create mode 100644 book-bazaar/src/app/components/author-details/author-details.ts create mode 100644 book-bazaar/src/app/components/authors/authors.css create mode 100644 book-bazaar/src/app/components/authors/authors.html create mode 100644 book-bazaar/src/app/components/authors/authors.ts create mode 100644 recommenderService/__pycache__/core_scoring_strategies.cpython-313.pyc diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..20d3474 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["-y", "@playwright/mcp@latest"] + } + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..bdd11d8 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(git checkout *)", + "Bash(git stash *)", + "Bash(npx playwright *)", + "Bash(npx tsc *)", + "Skill(update-config)", + "Skill(update-config:*)" + ] + } +} diff --git a/.gitignore b/.gitignore index c5bd65c..33132a0 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,10 @@ docker .vscode data/ml_models/ -phase1_datasets/ \ No newline at end of file +phase1_datasets/ + +recommenderService/evaluation_results/ + +recommenderService/**/__pycache__/ + +.claude/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bbd8a28 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,87 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Services at a Glance + +| Service | Stack | Port | Storage | +|---|---|---|---| +| `apiGateway` | Spring Cloud Gateway | 9000 | โ€” | +| `bookService` | Spring Boot 3 / JPA | 8080 | MySQL | +| `reviewService` | Spring Boot 3 / MongoDB | 8081 | MongoDB | +| `recommenderService` | FastAPI (Python) | 8082 | โ€” | +| `book-bazaar` | Angular 20 | 4200 | โ€” | + +## Running the Stack + +```bash +# Start all infrastructure + services +docker-compose up -d + +# Production build (builds JARs/images first) +docker-compose -f docker-compose.prod.yml up -d +``` + +Infrastructure brought up by Docker: MySQL (3307), Keycloak (8181), MongoDB (27018), Kafka (9092), Schema Registry (8085), Kafka UI (8083), Prometheus (9090), Grafana (3000), Mailhog (8025). + +## Build & Test Commands + +**Java services** (run from each service directory): +```bash +mvn clean package -DskipTests # build JAR +mvn test # run tests +mvn test -Dtest=MyTestClass # run a single test class +``` +Spring profiles: `dev` (default) and `prod`. Backend services read profile-specific config from `src/main/resources/application-dev.yml` / `application-prod.yml`. + +**Angular frontend** (`book-bazaar/`): +```bash +npm start # dev server at localhost:4200 +npm run build # production build +npm test # Karma/Jasmine unit tests +``` + +**Python recommender** (`recommenderService/`): +```bash +pip install -r requirements.txt +python main.py +``` + +## Architecture + +### Request Flow +Browser โ†’ Angular (4200) โ†’ **API Gateway** (9000) โ†’ `book-service` or `review-service` + +The gateway validates JWT tokens issued by Keycloak and relays them to backend services via a token relay filter. Service-to-service calls use OpenFeign clients with client-credentials flow. + +### Async Scoring Pipeline (Kafka) +1. `reviewService` publishes a `ReviewScoringRequest` (Avro) to `review-scoring-requests` after review **create** or text **update**. +2. `recommenderService` consumes it, runs cosine similarity with `all-MiniLM-L6-v2`, and publishes a `ReviewScoringResult` to `review-scoring-results`. +3. `reviewService` (`ReviewScoringResultListener`) consumes the result and persists it into `Review.scoringResult`. + +Avro schemas live in each service's `src/main/resources/avro/`. The `avro-maven-plugin` generates Java POJOs at `generate-sources` phase. + +### Key Patterns + +**AbstractController / AbstractService** โ€” all CRUD controllers and services extend base classes that provide pagination, soft-delete (archival via `archived` flag), and lifecycle hooks (`beforeCreate`, `afterCreate`, `beforeUpdate`, `beforeDelete`, `afterDelete`). Prefer adding logic in these hooks rather than overriding the full CRUD method. + +**Filtering** โ€” query string filters use the syntax `property:value` / `property!=value` / `property_=value` (contains). Multi-value OR uses `||`. Parsed by a `SearchCriteria` / `FilterableProperty` spec builder in each service. + +**DTOs** โ€” each entity has a `{Entity}Request` (input) and `{Entity}Response` (output) class; conversion goes through a `Mapper` injected by the controller base class. + +**Review metrics** โ€” `ReviewMetricsService` keeps denormalised rating aggregates on the book side; it is called on every review create/update/delete. + +### Auth +Keycloak realm `e-library` issues JWTs. Roles: `USER`, `ADMIN`. The gateway and each service are configured as OAuth2 resource servers pointing at `http://localhost:8181/realms/e-library`. + +## Key File Locations + +| What | Where | +|---|---| +| Gateway routes | `apiGateway/src/main/resources/application.yml` | +| Kafka topic names | `reviewService/src/main/resources/application-dev.yml` โ†’ `kafka.topics.*` | +| Avro schemas | `reviewService/src/main/resources/avro/` | +| Keycloak realm config | `infrastructure/keycloak/` | +| Prometheus config | `infrastructure/prometheus/prometheus-dev.yml` | +| MySQL init schema | `infrastructure/init.sql` | +| ML model config | `recommenderService/config.py` | diff --git a/book-bazaar/src/app/app.routes.ts b/book-bazaar/src/app/app.routes.ts index 0ea4f52..ff471b3 100644 --- a/book-bazaar/src/app/app.routes.ts +++ b/book-bazaar/src/app/app.routes.ts @@ -4,6 +4,8 @@ import { SearchBooks } from './components/search-books/search-books'; import { BookDetails } from './components/book-details/book-details'; import { ReviewForm } from './components/review-form/review-form'; import { MyReviews } from './components/my-reviews/my-reviews'; +import { AuthorsPage } from './components/authors/authors'; +import { AuthorDetails } from './components/author-details/author-details'; export const routes: Routes = [ { @@ -12,6 +14,12 @@ export const routes: Routes = [ { path: "search", component: SearchBooks }, + { + path: "authors", component: AuthorsPage + }, + { + path: "authors/:authorId", component: AuthorDetails + }, { path: "book-details/:bookId", component: BookDetails }, diff --git a/book-bazaar/src/app/components/author-details/author-details.css b/book-bazaar/src/app/components/author-details/author-details.css new file mode 100644 index 0000000..e69de29 diff --git a/book-bazaar/src/app/components/author-details/author-details.html b/book-bazaar/src/app/components/author-details/author-details.html new file mode 100644 index 0000000..ad46af5 --- /dev/null +++ b/book-bazaar/src/app/components/author-details/author-details.html @@ -0,0 +1,90 @@ +@if (loading()) { +
+ +
+} @else if (author(); as author) { + + +
+
+ + + chevron_left + All Authors + + +
+ +
+ {{ getInitials(author.name) }} +
+ +
+

+ {{ author.name }} +

+ + @if (author.bio) { +

+ {{ author.bio }} +

+ } @else { +

No biography available.

+ } + + +
+ +
+
+
+ + +
+ +
+

Books

+ @if (!booksLoading()) { + {{ totalBooks() }} title{{ totalBooks() !== 1 ? 's' : '' }} + } +
+ + @if (booksLoading()) { +
+ +
+ } @else if (books().length === 0) { +
+ auto_stories +

No books found for this author.

+
+ } @else { +
+ @for (book of books(); track book.id) { + + } +
+ + @if (totalBooks() > pageSize()) { +
+ + +
+ } + } + +
+} diff --git a/book-bazaar/src/app/components/author-details/author-details.ts b/book-bazaar/src/app/components/author-details/author-details.ts new file mode 100644 index 0000000..3d06e31 --- /dev/null +++ b/book-bazaar/src/app/components/author-details/author-details.ts @@ -0,0 +1,117 @@ +import { Component, inject, signal } from '@angular/core'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { map } from 'rxjs'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { Author } from '../../model/author'; +import { Book } from '../../model/book'; +import { AuthorService } from '../../services/author/author-service'; +import { BookService } from '../../services/book/book-service'; +import { BookCard } from '../book-card/book-card'; + +const AVATAR_GRADIENTS = [ + ['#8b5cf6', '#7c3aed'], + ['#3b82f6', '#4338ca'], + ['#10b981', '#0d9488'], + ['#f97316', '#d97706'], + ['#f43f5e', '#db2777'], + ['#06b6d4', '#0284c7'], +]; + +@Component({ + selector: 'app-author-details', + imports: [ + RouterLink, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule, + MatPaginatorModule, + BookCard, + ], + templateUrl: './author-details.html', + styleUrl: './author-details.css', +}) +export class AuthorDetails { + author = signal(null); + books = signal([]); + totalBooks = signal(0); + loading = signal(true); + booksLoading = signal(false); + pageIndex = signal(0); + pageSize = signal(12); + + private route = inject(ActivatedRoute); + private router = inject(Router); + private authorService = inject(AuthorService); + private bookService = inject(BookService); + + private authorId = toSignal( + this.route.paramMap.pipe(map(p => Number(p.get('authorId')))) + ); + + ngOnInit(): void { + const id = this.authorId(); + if (id && id > 0) { + this.loadAuthor(id); + } + } + + private loadAuthor(id: number): void { + this.loading.set(true); + this.authorService.getAuthorById(id).subscribe({ + next: (author) => { + this.author.set(author); + this.loading.set(false); + this.loadBooks(author.name!); + }, + error: () => { + this.loading.set(false); + }, + }); + } + + loadBooks(authorName: string): void { + this.booksLoading.set(true); + this.bookService + .getBooks({ author: authorName }, this.pageIndex(), this.pageSize(), 'title,asc') + .subscribe({ + next: (response) => { + this.books.set(response.items); + this.totalBooks.set(response.total); + this.booksLoading.set(false); + }, + error: () => { + this.books.set([]); + this.booksLoading.set(false); + }, + }); + } + + onPageChange(event: PageEvent): void { + this.pageIndex.set(event.pageIndex); + this.pageSize.set(event.pageSize); + if (this.author()?.name) { + this.loadBooks(this.author()!.name!); + } + } + + browseAllBooks(): void { + this.router.navigate(['/search'], { queryParams: { author: this.author()?.name } }); + } + + getInitials(name: string = ''): string { + return name + .split(' ') + .slice(0, 2) + .map(w => w[0]?.toUpperCase() ?? '') + .join(''); + } + + getAvatarStyle(name: string = ''): string { + const [from, to] = AVATAR_GRADIENTS[name.charCodeAt(0) % AVATAR_GRADIENTS.length]; + return `background: linear-gradient(135deg, ${from}, ${to})`; + } +} diff --git a/book-bazaar/src/app/components/authors/authors.css b/book-bazaar/src/app/components/authors/authors.css new file mode 100644 index 0000000..e69de29 diff --git a/book-bazaar/src/app/components/authors/authors.html b/book-bazaar/src/app/components/authors/authors.html new file mode 100644 index 0000000..dd7ce8d --- /dev/null +++ b/book-bazaar/src/app/components/authors/authors.html @@ -0,0 +1,105 @@ +
+
+

+ Discover Authors +

+

Explore the minds behind your favourite books

+ +
+ search + +
+
+
+ +
+ +
+

+ @if (!loading()) { + {{ totalAuthors() }} author{{ totalAuthors() !== 1 ? 's' : '' }} found + } +

+ + + + + + + +
+ + @if (loading()) { +
+ +
+ } @else if (authors().length === 0) { +
+ person_search +

No authors found

+

Try a different search term

+
+ } @else { +
+ @for (author of authors(); track author.id) { +
+ +
+
+ {{ getInitials(author.name) }} +
+
+

{{ author.name }}

+
+
+ +

+ {{ author.bio || 'No biography available.' }} +

+ + +
+ } +
+ + @if (totalAuthors() > pageSize()) { +
+ + +
+ } + } +
diff --git a/book-bazaar/src/app/components/authors/authors.ts b/book-bazaar/src/app/components/authors/authors.ts new file mode 100644 index 0000000..a3f4bbb --- /dev/null +++ b/book-bazaar/src/app/components/authors/authors.ts @@ -0,0 +1,135 @@ +import { Component, inject, signal } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MatMenuModule } from '@angular/material/menu'; +import { combineLatest } from 'rxjs'; +import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Author } from '../../model/author'; +import { AuthorService } from '../../services/author/author-service'; + +const AVATAR_GRADIENTS = [ + ['#8b5cf6', '#7c3aed'], + ['#3b82f6', '#4338ca'], + ['#10b981', '#0d9488'], + ['#f97316', '#d97706'], + ['#f43f5e', '#db2777'], + ['#06b6d4', '#0284c7'], +]; + +@Component({ + selector: 'app-authors', + imports: [ + ReactiveFormsModule, + MatButton, + MatIcon, + MatPaginatorModule, + MatProgressSpinner, + MatMenuModule, + ], + templateUrl: './authors.html', + styleUrl: './authors.css', +}) +export class AuthorsPage { + authors = signal([]); + totalAuthors = signal(0); + loading = signal(false); + pageIndex = signal(0); + pageSize = signal(12); + currentSort = signal('name,asc'); + + searchControl = new FormControl(''); + + private route = inject(ActivatedRoute); + private router = inject(Router); + private authorService = inject(AuthorService); + + constructor() { + this.searchControl.valueChanges.pipe( + debounceTime(500), + distinctUntilChanged(), + takeUntilDestroyed(), + ).subscribe(value => { + this.router.navigate([], { + relativeTo: this.route, + queryParams: { query: value || null, page: 0 }, + queryParamsHandling: 'merge', + }); + }); + } + + ngOnInit(): void { + combineLatest([this.route.queryParamMap]).subscribe({ + next: ([params]) => { + const page = Number(params.get('page') ?? 0); + const size = Number(params.get('size') ?? 12); + const sort = params.get('sort') ?? 'name,asc'; + const query = params.get('query') ?? ''; + + this.pageIndex.set(page); + this.pageSize.set(size); + this.currentSort.set(sort); + this.searchControl.setValue(query, { emitEvent: false }); + + this.loading.set(true); + + this.authorService.getAuthors(page, size, query || undefined, sort).subscribe({ + next: (response) => { + this.authors.set(response.items); + this.totalAuthors.set(response.total); + this.loading.set(false); + }, + error: () => { + this.authors.set([]); + this.loading.set(false); + }, + }); + }, + }); + } + + onPageChange(event: PageEvent): void { + this.router.navigate([], { + relativeTo: this.route, + queryParams: { page: event.pageIndex, size: event.pageSize }, + queryParamsHandling: 'merge', + }); + } + + updateSort(sort: string): void { + this.router.navigate([], { + relativeTo: this.route, + queryParams: { sort, page: 0 }, + queryParamsHandling: 'merge', + }); + } + + openDetails(author: Author): void { + this.router.navigate(['/authors', author.id]); + } + + viewBooks(author: Author): void { + this.router.navigate(['/search'], { queryParams: { author: author.name } }); + } + + getInitials(name: string = ''): string { + return name + .split(' ') + .slice(0, 2) + .map(w => w[0]?.toUpperCase() ?? '') + .join(''); + } + + getAvatarStyle(name: string = ''): string { + const [from, to] = AVATAR_GRADIENTS[name.charCodeAt(0) % AVATAR_GRADIENTS.length]; + return `background: linear-gradient(135deg, ${from}, ${to})`; + } + + sortLabel(): string { + return this.currentSort() === 'name,asc' ? 'A โ€“ Z' : 'Z โ€“ A'; + } +} 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 7242022..47aa2db 100644 --- a/book-bazaar/src/app/components/book-details/book-details.html +++ b/book-bazaar/src/app/components/book-details/book-details.html @@ -114,37 +114,45 @@

Book Details
-

Similar Books

- +

Readers Also Enjoyed

+

Based on this book's content

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

No similar books found.

+
+ auto_stories +

No similar books found.

+
} @else { -
- @for (book of similarBooks(); track book.id) { -
+ @for (similarBook of similarBooks(); track similarBook.id) { +
-
- + class="group flex gap-3 p-3 rounded-xl cursor-pointer hover:bg-gray-50 transition-all border border-transparent hover:border-gray-100 hover:shadow-sm"> + +
+
-

- {{ book.title }} -

-

{{ book.author?.name }}

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

+ {{ similarBook.title }} +

+

{{ similarBook.author?.name }}

+
+
+ star + {{ similarBook.averageRating?.toFixed(1) }} + ยท + {{ similarBook.totalReviews }} reviews +
} @@ -249,7 +257,7 @@

}

-
+
+
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 b4934cd..f8942a6 100644 --- a/book-bazaar/src/app/components/book-details/book-details.ts +++ b/book-bazaar/src/app/components/book-details/book-details.ts @@ -77,7 +77,7 @@ export class BookDetails { pageIndex = signal(0); pageSize = signal(10); - currentSort = signal('createdAt,desc'); + currentSort = signal('scoringResult.score,desc'); selectedRatingFilter = signal(undefined); userRating = signal(0); @@ -287,6 +287,7 @@ export class BookDetails { case 'createdAt,asc': return 'Oldest first'; case 'rating,desc': return 'Highest rated'; case 'rating,asc': return 'Lowest rated'; + case 'scoringResult.score,desc': return 'Most relevant'; default: return 'Sort by'; } } diff --git a/book-bazaar/src/app/components/header/header.html b/book-bazaar/src/app/components/header/header.html index 09a993a..0c6cfea 100644 --- a/book-bazaar/src/app/components/header/header.html +++ b/book-bazaar/src/app/components/header/header.html @@ -20,6 +20,11 @@ search Browse Books + + person + Authors +
diff --git a/book-bazaar/src/app/components/home/home.html b/book-bazaar/src/app/components/home/home.html index 56a5b95..1ab70c5 100644 --- a/book-bazaar/src/app/components/home/home.html +++ b/book-bazaar/src/app/components/home/home.html @@ -208,6 +208,82 @@

-
-
+ +
+
+ +
+

What Readers Are Saying

+

The most insightful reviews from our community

+
+
+ + @if (isLoadingTopReviews()) { +
+ +
+ } @else if (topReviews().length === 0) { +

No reviews available yet.

+ } @else { +
+ @for (review of topReviews(); track review.id) { +
+ + +
+
+ +
+
+

+ {{ review.bookTitle || 'Unknown Book' }} +

+

{{ review.bookAuthor }}

+
+
+ + +
+ +
+ @for (_ of [1,2,3,4,5]; track $index) { + star + } +
+ + +

+ "{{ review.text }}" +

+ + +
+
+
+ @if (review.avatarUrl) { + Avatar + } @else { + {{ review.firstName?.charAt(0) || 'U' }} + } +
+ + {{ review.firstName || 'Reader' }} {{ review.lastName || '' }} + +
+ {{ review.createdAt | date:'mediumDate' }} +
+
+ +
+ } +
+ } + +
+
diff --git a/book-bazaar/src/app/components/home/home.ts b/book-bazaar/src/app/components/home/home.ts index 925d7bc..b4f4964 100644 --- a/book-bazaar/src/app/components/home/home.ts +++ b/book-bazaar/src/app/components/home/home.ts @@ -9,13 +9,17 @@ import { Book } from '../../model/book'; import { BookService } from '../../services/book/book-service'; import { BookCard } from "../book-card/book-card"; import { GenreService } from '../../services/genre/genre-service'; -import { debounceTime, distinctUntilChanged, switchMap, filter, tap, catchError } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, switchMap, filter, tap, catchError, map } from 'rxjs/operators'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { of } from 'rxjs'; -import { CurrencyPipe, DecimalPipe } from '@angular/common'; +import { forkJoin, of } from 'rxjs'; +import { CurrencyPipe, DecimalPipe, DatePipe } from '@angular/common'; import { MatIcon } from "@angular/material/icon"; import { UserService } from '../../services/user/userService'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { ReviewService } from '../../services/review/review-service'; +import { Review } from '../../model/review'; + +type ReviewWithBook = Review & { bookTitle?: string; bookImageUrl?: string; bookAuthor?: string }; @Component({ selector: 'app-home', @@ -26,6 +30,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; BookCard, CurrencyPipe, DecimalPipe, + DatePipe, RouterModule, MatIcon, MatTooltipModule, @@ -39,6 +44,7 @@ export class Home implements OnInit { protected bookService = inject(BookService); protected genreService = inject(GenreService); protected userService = inject(UserService); + protected reviewService = inject(ReviewService); protected formGroup: FormGroup = new FormGroup( { @@ -57,14 +63,20 @@ export class Home implements OnInit { protected liveSearchResults = signal([]); protected showDropdown = signal(false); + protected topReviews = signal([]); + protected isLoadingTopReviews = signal(false); + constructor() { this.setupLiveSearch(); } ngOnInit() { this.loadRecommendations(); + this.loadTopRelevantReviews(); this.genreService.getGenres(0, 6) .subscribe(page => this.browsingGenres.set(page.items)); + this.bookService.getBooks({}, 0, 10, 'totalReviews,desc') + .subscribe(page => this.popularBooks.set(page.items)); } private setupLiveSearch() { @@ -142,6 +154,34 @@ export class Home implements OnInit { }); } + protected loadTopRelevantReviews(): void { + this.isLoadingTopReviews.set(true); + this.reviewService.getTopRelevantReviews(6).pipe( + switchMap(page => { + const reviews = page.items.filter(r => r.text?.trim()); + if (reviews.length === 0) return of([] as ReviewWithBook[]); + const uniqueIds = [...new Set(reviews.map(r => r.bookId))]; + return forkJoin( + uniqueIds.map(id => this.bookService.getBookById(id).pipe(catchError(() => of(null)))) + ).pipe( + map(books => { + const bookMap = new Map(books.filter(Boolean).map(b => [b!.id, b!])); + return reviews.map(r => ({ + ...r, + bookTitle: bookMap.get(r.bookId)?.title, + bookImageUrl: bookMap.get(r.bookId)?.imageUrl, + bookAuthor: bookMap.get(r.bookId)?.author?.name, + })); + }) + ); + }), + catchError(() => of([] as ReviewWithBook[])) + ).subscribe(reviews => { + this.topReviews.set(reviews); + this.isLoadingTopReviews.set(false); + }); + } + protected search() { let filter = this.formGroup.value.search; if (!filter) return; diff --git a/book-bazaar/src/app/model/review.ts b/book-bazaar/src/app/model/review.ts index d562854..cd0bd3c 100644 --- a/book-bazaar/src/app/model/review.ts +++ b/book-bazaar/src/app/model/review.ts @@ -8,4 +8,5 @@ export interface Review { createdAt: string; rating: number; text: string; + relevanceScore?: number; } \ No newline at end of file diff --git a/book-bazaar/src/app/services/author/author-service.ts b/book-bazaar/src/app/services/author/author-service.ts index 610ec13..6814bb1 100644 --- a/book-bazaar/src/app/services/author/author-service.ts +++ b/book-bazaar/src/app/services/author/author-service.ts @@ -11,11 +11,20 @@ export class AuthorService { private http = inject(HttpClient); private apiUrl = 'http://localhost:9000/book-service/api/authors'; - getAuthors(page: number = 0, size: number = 10): Observable> { + getAuthors(page: number = 0, size: number = 12, search?: string, sort: string = 'name,asc'): Observable> { let params = new HttpParams() .set('pageIndex', page) - .set('pageSize', size); + .set('pageSize', size) + .set('sort', sort); + + if (search) { + params = params.set('search', `name:${search}`); + } return this.http.get>(this.apiUrl, { params }); } + + getAuthorById(id: number): Observable { + return this.http.get(`${this.apiUrl}/${id}`); + } } diff --git a/book-bazaar/src/app/services/review/review-service.ts b/book-bazaar/src/app/services/review/review-service.ts index 1d0fb2f..9617b94 100644 --- a/book-bazaar/src/app/services/review/review-service.ts +++ b/book-bazaar/src/app/services/review/review-service.ts @@ -77,6 +77,14 @@ export class ReviewService { return this.http.get>(`${this.apiUrl}/reviews`, { params }); } + getTopRelevantReviews(size: number = 6): Observable> { + const params = new HttpParams() + .set('pageIndex', 0) + .set('pageSize', size) + .set('sort', 'scoringResult.score,desc'); + return this.http.get>(`${this.apiUrl}/reviews`, { params }); + } + deleteReview(reviewId: string): Observable { return this.http.delete(`${this.apiUrl}/reviews/${reviewId}`); } diff --git a/bookService/src/main/java/org/library/bookservice/service/BookService.java b/bookService/src/main/java/org/library/bookservice/service/BookService.java index 8290173..4df3f39 100644 --- a/bookService/src/main/java/org/library/bookservice/service/BookService.java +++ b/bookService/src/main/java/org/library/bookservice/service/BookService.java @@ -1,21 +1,24 @@ package org.library.bookservice.service; import event.BookDeletedEvent; -import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; import org.library.bookservice.dao.AbstractDao; import org.library.bookservice.dao.BookDao; import org.library.bookservice.model.Book; +import org.springframework.beans.factory.annotation.Value; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Service; -@AllArgsConstructor +@RequiredArgsConstructor @Service public class BookService extends AbstractService { - private BookDao dao; - + private final BookDao dao; private final KafkaTemplate kafkaTemplate; + @Value("${kafka.topics.book-deleted:book-deleted}") + private String bookDeletedTopic; + @Override protected AbstractDao getDao() { return dao; @@ -23,6 +26,6 @@ protected AbstractDao getDao() { @Override protected void afterDelete(Book entity) { - kafkaTemplate.send("book-deleted", new BookDeletedEvent(entity.getId())); + kafkaTemplate.send(bookDeletedTopic, new BookDeletedEvent(entity.getId())); } } diff --git a/bookService/src/main/java/org/library/bookservice/service/listener/BookRatingUpdateListener.java b/bookService/src/main/java/org/library/bookservice/service/listener/BookRatingUpdateListener.java index 4b2f854..6f083ff 100644 --- a/bookService/src/main/java/org/library/bookservice/service/listener/BookRatingUpdateListener.java +++ b/bookService/src/main/java/org/library/bookservice/service/listener/BookRatingUpdateListener.java @@ -15,7 +15,7 @@ public class BookRatingUpdateListener { private final BookService bookService; - @KafkaListener(topics = "book-rating-updated", groupId = "book-service-group") + @KafkaListener(topics = "${kafka.topics.book-rating-updated:book-rating-updated}", groupId = "${spring.kafka.consumer.group-id}") @Transactional public void handleBookRatingUpdate(BookRatingUpdatedEvent event) { log.info("Received rating update for book {}: {}", event.getBookId(), event.getAverageRating()); diff --git a/bookService/src/main/resources/application-dev.yml b/bookService/src/main/resources/application-dev.yml index 9bb5e2f..699bb62 100644 --- a/bookService/src/main/resources/application-dev.yml +++ b/bookService/src/main/resources/application-dev.yml @@ -25,6 +25,11 @@ spring: jwk-set-uri: http://localhost:8181/realms/e-library/protocol/openid-connect/certs token-uri: http://localhost:8181/realms/e-library/protocol/openid-connect/token +kafka: + topics: + book-deleted: book-deleted + book-rating-updated: book-rating-updated + gateway: url: http://localhost:9000 diff --git a/bookService/src/main/resources/application-prod.yml b/bookService/src/main/resources/application-prod.yml index f100b0f..01aea25 100644 --- a/bookService/src/main/resources/application-prod.yml +++ b/bookService/src/main/resources/application-prod.yml @@ -24,6 +24,11 @@ spring: issuer-uri: http://keycloak:8080/realms/e-library token-uri: http://keycloak:8080/realms/e-library/protocol/openid-connect/token +kafka: + topics: + book-deleted: book-deleted + book-rating-updated: book-rating-updated + gateway: url: http://api-gateway:9000 diff --git a/bookService/src/main/resources/application.properties b/bookService/src/main/resources/application.properties index 077a240..5233b95 100644 --- a/bookService/src/main/resources/application.properties +++ b/bookService/src/main/resources/application.properties @@ -13,7 +13,6 @@ spring.kafka.consumer.properties.spring.deserializer.key.delegate.class=org.apac spring.kafka.consumer.properties.spring.deserializer.value.delegate.class=io.confluent.kafka.serializers.KafkaAvroDeserializer spring.kafka.consumer.properties.specific.avro.reader=true spring.kafka.consumer.group-id=book-service-group -spring.kafka.template.default-topic=book-deleted spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer spring.kafka.producer.value-serializer=io.confluent.kafka.serializers.KafkaAvroSerializer diff --git a/recommenderService/__pycache__/core_scoring_strategies.cpython-313.pyc b/recommenderService/__pycache__/core_scoring_strategies.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a198643caacd8284cfa1803761f3cbcaaa0bfbbc GIT binary patch literal 45871 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

F{D4*3)FvrygU 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 J*RZAk{{q|-kyQWy literal 0 HcmV?d00001 diff --git a/reviewService/src/main/java/org/library/reviewService/datagen/InitialDataGenerator.java b/reviewService/src/main/java/org/library/reviewService/datagen/InitialDataGenerator.java index 5b63f56..6d05bf0 100644 --- a/reviewService/src/main/java/org/library/reviewService/datagen/InitialDataGenerator.java +++ b/reviewService/src/main/java/org/library/reviewService/datagen/InitialDataGenerator.java @@ -4,12 +4,17 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.library.reviewService.client.BookServiceClient; +import org.library.reviewService.client.response.BookResponse; +import org.library.reviewService.integration.producer.ReviewScoringRequestProducer; import org.library.reviewService.model.Review; import org.library.reviewService.repository.ReviewRepository; import org.library.reviewService.service.ReviewMetricsService; +import org.library.reviewService.util.BookMetadataAggregator; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; +import org.springframework.http.ResponseEntity; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @@ -19,7 +24,9 @@ import java.nio.file.Paths; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; @Service @RequiredArgsConstructor @@ -29,6 +36,9 @@ public class InitialDataGenerator { private final ReviewRepository reviewRepository; private final ReviewMetricsService reviewMetricsService; private final ObjectMapper objectMapper; + private final BookServiceClient bookServiceClient; + private final ReviewScoringRequestProducer reviewScoringRequestProducer; + private final BookMetadataAggregator bookMetadataAggregator; @Value("${data.seeding.folder}") private String seedingFolder; @@ -79,9 +89,12 @@ public void onApplicationReady() { reviews.add(review); } + Map bookMetadataCache = new HashMap<>(); + reviews.forEach(r -> { reviewRepository.save(r); reviewMetricsService.addReviewMetrics(r.getBookId(), r.getRating()); + triggerRelevanceScoring(r, bookMetadataCache); }); log.info("Successfully loaded {} reviews into database", reviews.size()); @@ -91,9 +104,21 @@ public void onApplicationReady() { } } - /** - * Helper method to read JSON files. - */ + private void triggerRelevanceScoring(Review review, Map bookMetadataCache) { + try { + String bookMetadata = bookMetadataCache.computeIfAbsent(review.getBookId(), bookId -> { + ResponseEntity response = bookServiceClient.getById(bookId); + return response.hasBody() + ? bookMetadataAggregator.aggregateMetadata(response.getBody()) + : "{}"; + }); + reviewScoringRequestProducer.publishReviewScoringRequest(review, bookMetadata); + } catch (Exception e) { + log.warn("Failed to trigger relevance scoring for seeded review {}: {}", + review.getId(), e.getMessage()); + } + } + private List loadData(String fileName, TypeReference> typeReference) throws IOException { String fullPath = Paths.get(seedingFolder, fileName).toString(); diff --git a/reviewService/src/main/java/org/library/reviewService/dto/review/ReviewResponse.java b/reviewService/src/main/java/org/library/reviewService/dto/review/ReviewResponse.java index 29a5464..f80a61a 100644 --- a/reviewService/src/main/java/org/library/reviewService/dto/review/ReviewResponse.java +++ b/reviewService/src/main/java/org/library/reviewService/dto/review/ReviewResponse.java @@ -29,4 +29,6 @@ public class ReviewResponse extends AbstractResponse { private Integer rating; private String text; + + private Double relevanceScore; } diff --git a/reviewService/src/main/java/org/library/reviewService/integration/consumer/BookDeletedListener.java b/reviewService/src/main/java/org/library/reviewService/integration/consumer/BookDeletedListener.java index d5c667e..0cb58c0 100644 --- a/reviewService/src/main/java/org/library/reviewService/integration/consumer/BookDeletedListener.java +++ b/reviewService/src/main/java/org/library/reviewService/integration/consumer/BookDeletedListener.java @@ -16,7 +16,7 @@ public class BookDeletedListener { private ReviewService reviewService; - @KafkaListener(topics = "book-deleted") + @KafkaListener(topics = "${kafka.topics.book-deleted:book-deleted}") public void listener(BookDeletedEvent message) { log.info("Book deleted event fired: {}", message); reviewService.deleteByQuery(new Query(Criteria.where("bookId").is(message.getBookId()))); diff --git a/reviewService/src/main/java/org/library/reviewService/integration/producer/BookRatingUpdatedProducer.java b/reviewService/src/main/java/org/library/reviewService/integration/producer/BookRatingUpdatedProducer.java index 7c01654..8032bed 100644 --- a/reviewService/src/main/java/org/library/reviewService/integration/producer/BookRatingUpdatedProducer.java +++ b/reviewService/src/main/java/org/library/reviewService/integration/producer/BookRatingUpdatedProducer.java @@ -14,7 +14,7 @@ @Service public class BookRatingUpdatedProducer { - @Value("${spring.kafka.template.default-topic}") + @Value("${kafka.topics.book-rating-updated:book-rating-updated}") private String bookRatingUpdatedTopic; private final KafkaTemplate kafkaTemplate; diff --git a/reviewService/src/main/java/org/library/reviewService/mapper/ReviewMapper.java b/reviewService/src/main/java/org/library/reviewService/mapper/ReviewMapper.java index a2d9558..d13bcdd 100644 --- a/reviewService/src/main/java/org/library/reviewService/mapper/ReviewMapper.java +++ b/reviewService/src/main/java/org/library/reviewService/mapper/ReviewMapper.java @@ -64,6 +64,11 @@ public ReviewResponse entityToResponse(Review entity) { .createdAt(entity.getCreatedAt()) .rating(entity.getRating()) .text(entity.getText()) + .relevanceScore( + entity.getScoringResult() != null && entity.getScoringResult().isSuccessful() + ? entity.getScoringResult().getScore() + : null + ) .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 6917ff0..8e5e6d6 100644 --- a/reviewService/src/main/java/org/library/reviewService/service/ReviewService.java +++ b/reviewService/src/main/java/org/library/reviewService/service/ReviewService.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.NoSuchElementException; +import java.util.Objects; import java.util.Optional; @Service @@ -108,6 +109,20 @@ public Review update(Review entity) { metricsService.updateMetricsAfterEdit(saved.getBookId(), oldRating, newRating); + if (!Objects.equals(oldReview.getText(), saved.getText())) { + try { + ResponseEntity bookResponse = bookClient.getById(saved.getBookId()); + if (bookResponse.hasBody()) { + String bookMetadata = bookMetadataAggregator.aggregateMetadata( + (BookResponse) bookResponse.getBody()); + reviewScoringRequestProducer.publishReviewScoringRequest(saved, bookMetadata); + } + } catch (Exception e) { + log.warn("Failed to trigger review scoring for updated review {}: {}", + saved.getId(), e.getMessage()); + } + } + return saved; } diff --git a/reviewService/src/main/resources/application-dev.yml b/reviewService/src/main/resources/application-dev.yml index a66a282..e0094b1 100644 --- a/reviewService/src/main/resources/application-dev.yml +++ b/reviewService/src/main/resources/application-dev.yml @@ -25,6 +25,13 @@ spring: registry: url: http://localhost:8085 +kafka: + topics: + book-deleted: book-deleted + book-rating-updated: book-rating-updated + review-scoring-request: review-scoring-requests + review-scoring-result: review-scoring-results + book-service: url: http://localhost:8080 gateway: diff --git a/reviewService/src/main/resources/application-prod.yml b/reviewService/src/main/resources/application-prod.yml index 6966160..57442a1 100644 --- a/reviewService/src/main/resources/application-prod.yml +++ b/reviewService/src/main/resources/application-prod.yml @@ -27,6 +27,8 @@ spring: kafka: topics: + book-deleted: book-deleted + book-rating-updated: book-rating-updated review-scoring-request: review-scoring-requests review-scoring-result: review-scoring-results diff --git a/reviewService/src/main/resources/application.properties b/reviewService/src/main/resources/application.properties index 036511f..e1d6b13 100644 --- a/reviewService/src/main/resources/application.properties +++ b/reviewService/src/main/resources/application.properties @@ -18,7 +18,6 @@ spring.kafka.consumer.properties.spring.deserializer.value.delegate.class=io.con spring.kafka.consumer.properties.specific.avro.reader=true spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer spring.kafka.producer.value-serializer=io.confluent.kafka.serializers.KafkaAvroSerializer -spring.kafka.template.default-topic=book-rating-updated management.endpoints.web.exposure.include=health, metrics, prometheus management.endpoint.health.show-details=always