-
Notifications
You must be signed in to change notification settings - Fork 101
Implemented design patterns consistently #443
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| # TeachLink Design Patterns | ||
|
|
||
| This document outlines the standard software design patterns implemented across the TeachLink ecosystem. Utilizing standard design patterns ensures code maintainability, scalability, and consistency for onboarding new contributors. | ||
|
|
||
| ## 1. Strategy Pattern | ||
|
|
||
| **Context**: Conditional branches that change behavior based on a type or flag (e.g., generating explanations for AI recommendations) often result in large, unmaintainable `switch` blocks. | ||
| **Usage**: Used in `ExplanationGenerator` (`explainability.ts`) to dynamically select the correct algorithm for extracting dominant signal explanations. | ||
|
|
||
| **Example**: | ||
| ```typescript | ||
| export interface ExplanationStrategy { | ||
| matches(dominantSignal: string): boolean; | ||
| extract(rankingSignal: any, userProfile: Types.UserProfile, similarUsers?: string[]): ExplanationResult; | ||
| } | ||
|
|
||
| class ContentSignalStrategy implements ExplanationStrategy { | ||
| matches(signal: string) { return signal === 'contentSignal'; } | ||
| extract(...) { ... } | ||
| } | ||
| ``` | ||
|
|
||
| ## 2. Builder Pattern | ||
|
|
||
| **Context**: Classes requiring multiple configuration options and dependencies (especially when many are optional) often lead to confusing constructors (the "telescoping constructor" anti-pattern). | ||
| **Usage**: Implemented for `PrivacyComplianceManager` in `privacy.ts` to cleanly instantiate the manager and supply various privacy engines systematically. | ||
|
|
||
| **Example**: | ||
| ```typescript | ||
| const privacyManager = new PrivacyComplianceManagerBuilder() | ||
| .withAnonymizer(new UserAnonymizer()) | ||
| .withDifferentialPrivacy(new DifferentialPrivacyEngine(0.5)) | ||
| .build(); | ||
| ``` | ||
|
|
||
| ## 3. Facade Pattern | ||
|
|
||
| **Context**: Complex subsystems with many interconnected mechanisms shouldn't expose their granular logic to the application's business layer. | ||
| **Usage**: `PrivacyComplianceManager` acts as a facade, exposing clean, high-level methods like `processUserDataPrivate` while hiding the interactions between `UserAnonymizer`, `DifferentialPrivacyEngine`, and `DataMinimizer`. | ||
|
|
||
| ## 4. Dependency Injection (DI) | ||
|
|
||
| **Context**: Hardcoding dependencies tightly couples classes and makes unit testing incredibly difficult. | ||
| **Usage**: Across the NestJS Indexer and system layers, components are injected via class constructors. | ||
| **Example**: The `LoggerService` is instantiated by the framework and passed dynamically to processors, allowing the testing environment to swap it for a mock logger without modifying the core service. | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,58 @@ | ||||||||||||||||||||||
| # TeachLink Logging Strategy | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ## Overview | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| This document defines the standard logging patterns, formats, and levels across the TeachLink ecosystem. Consistent, structured logging is critical for observability, debugging, and security auditing for both off-chain infrastructure (e.g., the Indexer) and on-chain smart contracts. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ## 1. Log Levels | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| The following standard log levels MUST be used across all off-chain services: | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| - **`error`**: System failures, critical errors requiring immediate attention, uncaught exceptions, and transaction failures. | ||||||||||||||||||||||
| - **`warn`**: Non-critical errors, anomalous but recoverable states, deprecated API usage, and retries. | ||||||||||||||||||||||
| - **`info`**: Normal system operations, business logic milestones (e.g., indexer block processing), startup/shutdown events. | ||||||||||||||||||||||
| - **`debug`**: Detailed diagnostic information, granular state transitions, payload dumps, and tracing for local development. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| *Note: The environment variable `LOG_LEVEL` dictates the minimum severity level output by the application.* | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ## 2. Structured Logging Format | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| All off-chain services MUST output logs in **Structured JSON format** when running in production (`NODE_ENV=production`). This ensures seamless ingestion by modern log aggregators and monitoring tools (e.g., Datadog, ELK, CloudWatch). | ||||||||||||||||||||||
|
Comment on lines
+16
to
+20
|
||||||||||||||||||||||
| *Note: The environment variable `LOG_LEVEL` dictates the minimum severity level output by the application.* | |
| ## 2. Structured Logging Format | |
| All off-chain services MUST output logs in **Structured JSON format** when running in production (`NODE_ENV=production`). This ensures seamless ingestion by modern log aggregators and monitoring tools (e.g., Datadog, ELK, CloudWatch). | |
| *Note: The logger uses the application configuration key `app.logLevel` to determine the minimum severity level output by the application. If environment variables are used in a deployment, they should be mapped into `app.logLevel` via the Nest configuration layer rather than assumed to be read directly by the logger.* | |
| ## 2. Structured Logging Format | |
| All off-chain services MUST output logs in **Structured JSON format** when running in production (`app.nodeEnv = production`). This ensures seamless ingestion by modern log aggregators and monitoring tools (e.g., Datadog, ELK, CloudWatch). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import { Module, Global } from '@nestjs/common'; | ||
| import { ConfigModule } from '@nestjs/config'; | ||
| import { LoggerService } from './logger.service'; | ||
|
|
||
| @Global() | ||
| @Module({ | ||
| imports: [ConfigModule], | ||
| providers: [LoggerService], | ||
| exports: [LoggerService], | ||
| }) | ||
| export class LoggerModule {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| import { LoggerService as NestLoggerService, Injectable, Scope } from '@nestjs/common'; | ||
| import { ConfigService } from '@nestjs/config'; | ||
|
|
||
| @Injectable({ scope: Scope.TRANSIENT }) | ||
| export class LoggerService implements NestLoggerService { | ||
| private context?: string; | ||
| private readonly isProduction: boolean; | ||
| private readonly logLevel: string; | ||
|
|
||
| constructor(private configService: ConfigService) { | ||
| this.isProduction = this.configService.get<string>('app.nodeEnv') === 'production'; | ||
| this.logLevel = this.configService.get<string>('app.logLevel') || 'info'; | ||
| } | ||
|
|
||
| setContext(context: string) { | ||
| this.context = context; | ||
| } | ||
|
|
||
| private shouldLog(level: string): boolean { | ||
| const levels = ['debug', 'info', 'warn', 'error']; | ||
| const configuredIndex = levels.indexOf(this.logLevel.toLowerCase()); | ||
| const targetIndex = levels.indexOf(level); | ||
| return targetIndex >= configuredIndex; | ||
| } | ||
|
Comment on lines
+19
to
+24
|
||
|
|
||
| private formatMessage(level: string, message: any, optionalParams: any[]) { | ||
| const timestamp = new Date().toISOString(); | ||
|
|
||
| let metadata = {}; | ||
| let currentContext = this.context || 'Application'; | ||
|
|
||
| if (optionalParams.length > 0) { | ||
| // Extract trailing string arguments (usually used as the context internally by NestJS) | ||
| if (typeof optionalParams[optionalParams.length - 1] === 'string') { | ||
| currentContext = optionalParams.pop(); | ||
| } | ||
|
|
||
| // Next trailing param logic checks if it's an object intended for structured metadata logging | ||
| if (optionalParams.length > 0) { | ||
| const lastParam = optionalParams[optionalParams.length - 1]; | ||
| if (typeof lastParam === 'object' && lastParam !== null) { | ||
| metadata = optionalParams.pop(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const logEntry = { | ||
| timestamp, | ||
| level, | ||
| context: currentContext, | ||
| message: typeof message === 'object' ? JSON.stringify(message) : message, | ||
| ...(Object.keys(metadata).length > 0 ? { data: metadata } : {}), | ||
| }; | ||
|
Comment on lines
+47
to
+53
|
||
|
|
||
| if (this.isProduction) { | ||
| return JSON.stringify(logEntry); | ||
| } | ||
|
|
||
| // Human-readable format output tailored for development environments | ||
| const colorCode = this.getColorCode(level); | ||
| const resetCode = '\x1b[0m'; | ||
| const metadataStr = Object.keys(metadata).length > 0 ? `\n\x1b[33m[Data]: ${JSON.stringify(metadata, null, 2)}\x1b[0m` : ''; | ||
|
|
||
| return `${colorCode}[${timestamp}] [${level.toUpperCase()}] [${currentContext}] ${logEntry.message}${resetCode}${metadataStr}`; | ||
| } | ||
|
|
||
| private getColorCode(level: string): string { | ||
| switch (level) { | ||
| case 'error': return '\x1b[31m'; // Red | ||
| case 'warn': return '\x1b[33m'; // Yellow | ||
| case 'info': return '\x1b[32m'; // Green | ||
| case 'debug': return '\x1b[36m'; // Cyan | ||
| default: return '\x1b[37m'; // White | ||
| } | ||
| } | ||
|
|
||
| log(message: any, ...optionalParams: any[]) { | ||
| if (!this.shouldLog('info')) return; | ||
| console.log(this.formatMessage('info', message, optionalParams)); | ||
| } | ||
|
|
||
| error(message: any, ...optionalParams: any[]) { | ||
| if (!this.shouldLog('error')) return; | ||
| console.error(this.formatMessage('error', message, optionalParams)); | ||
| } | ||
|
|
||
| warn(message: any, ...optionalParams: any[]) { | ||
| if (!this.shouldLog('warn')) return; | ||
| console.warn(this.formatMessage('warn', message, optionalParams)); | ||
| } | ||
|
|
||
| debug(message: any, ...optionalParams: any[]) { | ||
| if (!this.shouldLog('debug')) return; | ||
| console.debug(this.formatMessage('debug', message, optionalParams)); | ||
| } | ||
|
|
||
| verbose?(message: any, ...optionalParams: any[]) { | ||
| if (!this.shouldLog('debug')) return; | ||
| console.debug(this.formatMessage('debug', message, optionalParams)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -30,53 +30,17 @@ export class ExplanationGenerator { | |||
| similarContent?: Array<[string, number]>, | ||||
| similarUsers?: string[] | ||||
| ): Types.RecommendationExplanation { | ||||
| let primaryReason = ''; | ||||
| const supportingSignals: string[] = []; | ||||
| const featureAttribution: Array<{ | ||||
| feature: string; | ||||
| importance: number; | ||||
| contribution: string; | ||||
| }> = []; | ||||
|
|
||||
| // Determine dominant signal | ||||
| const signals = Object.entries(rankingSignal); | ||||
| const [dominantSignal] = signals.reduce((a, b) => (a[1] > b[1] ? a : b)); | ||||
|
|
||||
| // Generate primary reason and supporting signals | ||||
| if (dominantSignal === 'collaborativeSignal' && similarUsers && similarUsers.length > 0) { | ||||
| primaryReason = `Users like you enjoyed this content`; | ||||
| supportingSignals.push(`Liked by ${similarUsers.length} similar learners`); | ||||
| featureAttribution.push({ | ||||
| feature: 'user_similarity', | ||||
| importance: rankingSignal.collaborativeSignal, | ||||
| contribution: `Based on similar learning patterns`, | ||||
| }); | ||||
| } else if (dominantSignal === 'contentSignal') { | ||||
| primaryReason = `Matches your interests`; | ||||
| const topics = Array.from(userProfile.features.topicAffinities.keys()).slice(0, 2); | ||||
| supportingSignals.push(`Related to your interest in ${topics.join(' and ')}`); | ||||
| featureAttribution.push({ | ||||
| feature: 'topic_match', | ||||
| importance: rankingSignal.contentSignal, | ||||
| contribution: `Content topic alignment with your profile`, | ||||
| }); | ||||
| } else if (dominantSignal === 'learningPathSignal') { | ||||
| primaryReason = `Recommended based on your learning path`; | ||||
| supportingSignals.push(`Prerequisite for your next goal`); | ||||
| featureAttribution.push({ | ||||
| feature: 'learning_path_fit', | ||||
| importance: rankingSignal.learningPathSignal, | ||||
| contribution: `Aligns with recommended progression`, | ||||
| }); | ||||
| } else if (dominantSignal === 'qualitySignal') { | ||||
| primaryReason = `High-quality content`; | ||||
| supportingSignals.push(`Highly rated by other learners`); | ||||
| featureAttribution.push({ | ||||
| feature: 'content_quality', | ||||
| importance: rankingSignal.qualitySignal, | ||||
| contribution: `Strong engagement and completion metrics`, | ||||
| }); | ||||
| } | ||||
| const { primaryReason, supportingSignals, featureAttribution } = this.extractDominantSignalExplanation( | ||||
| dominantSignal, | ||||
| rankingSignal, | ||||
| userProfile, | ||||
| similarUsers | ||||
| ); | ||||
|
|
||||
| // Add modality preference explanation | ||||
| if (userProfile.features.preferredModality === Types.ContentModality.VIDEO) { | ||||
|
|
@@ -105,20 +69,88 @@ export class ExplanationGenerator { | |||
| }; | ||||
| } | ||||
|
|
||||
| /** | ||||
| * Extracts the explanation based on the dominant ranking signal | ||||
| */ | ||||
| private extractDominantSignalExplanation( | ||||
| dominantSignal: string, | ||||
| rankingSignal: any, | ||||
| userProfile: Types.UserProfile, | ||||
| similarUsers?: string[] | ||||
| ): { | ||||
|
Comment on lines
+75
to
+80
|
||||
| primaryReason: string; | ||||
| supportingSignals: string[]; | ||||
| featureAttribution: Array<{ feature: string; importance: number; contribution: string }>; | ||||
| } { | ||||
| const result = { | ||||
| primaryReason: '', | ||||
| supportingSignals: [] as string[], | ||||
| featureAttribution: [] as Array<{ feature: string; importance: number; contribution: string }>, | ||||
| }; | ||||
|
|
||||
| switch (dominantSignal) { | ||||
| case 'collaborativeSignal': | ||||
| if (!similarUsers || similarUsers.length === 0) break; // Guard clause | ||||
| result.primaryReason = `Users like you enjoyed this content`; | ||||
| result.supportingSignals.push(`Liked by ${similarUsers.length} similar learners`); | ||||
| result.featureAttribution.push({ | ||||
| feature: 'user_similarity', | ||||
| importance: rankingSignal.collaborativeSignal, | ||||
| contribution: `Based on similar learning patterns`, | ||||
| }); | ||||
| break; | ||||
| case 'contentSignal': | ||||
| result.primaryReason = `Matches your interests`; | ||||
| const topics = Array.from(userProfile.features.topicAffinities.keys()).slice(0, 2); | ||||
| result.supportingSignals.push(`Related to your interest in ${topics.join(' and ')}`); | ||||
| result.featureAttribution.push({ | ||||
| feature: 'topic_match', | ||||
| importance: rankingSignal.contentSignal, | ||||
| contribution: `Content topic alignment with your profile`, | ||||
| }); | ||||
| break; | ||||
| case 'learningPathSignal': | ||||
| result.primaryReason = `Recommended based on your learning path`; | ||||
| result.supportingSignals.push(`Prerequisite for your next goal`); | ||||
| result.featureAttribution.push({ | ||||
| feature: 'learning_path_fit', | ||||
| importance: rankingSignal.learningPathSignal, | ||||
| contribution: `Aligns with recommended progression`, | ||||
| }); | ||||
| break; | ||||
| case 'qualitySignal': | ||||
| result.primaryReason = `High-quality content`; | ||||
| result.supportingSignals.push(`Highly rated by other learners`); | ||||
| result.featureAttribution.push({ | ||||
| feature: 'content_quality', | ||||
| importance: rankingSignal.qualitySignal, | ||||
| contribution: `Strong engagement and completion metrics`, | ||||
| }); | ||||
| break; | ||||
| } | ||||
|
|
||||
| return result; | ||||
| } | ||||
|
|
||||
| /** | ||||
| * Rule-based explanation generator | ||||
| */ | ||||
| private generateRuleBasedExplanation( | ||||
| userProfile: Types.UserProfile, | ||||
| signals: string[] | ||||
| ): string { | ||||
| if (!userProfile) return ''; // Guard clause | ||||
|
|
||||
|
Comment on lines
+142
to
+143
|
||||
| if (!userProfile) return ''; // Guard clause |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current
ExplanationGeneratorimplementation still uses aswitchinsideextractDominantSignalExplanation, so this doc describes a Strategy Pattern that isn't implemented by the code shown in this PR. Either (a) update this document to reflect the current refactor (helper method +switch), or (b) implement the documented strategy approach (e.g., a list/map of strategy objects selected bymatches(...)).