Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/DESIGN_PATTERNS.md
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.
Comment on lines +7 to +8
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current ExplanationGenerator implementation still uses a switch inside extractDominantSignalExplanation, 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 by matches(...)).

Copilot uses AI. Check for mistakes.

**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.
58 changes: 58 additions & 0 deletions docs/LOGGING_STRATEGY.md
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
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This conflicts with the added LoggerService, which reads app.logLevel (and app.nodeEnv) from ConfigService rather than LOG_LEVEL / NODE_ENV directly. Align the documentation with the actual configuration keys used by the logger, or update the logger to consume the documented env vars (potentially via Nest config mapping).

Suggested change
*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).

Copilot uses AI. Check for mistakes.

### Standard JSON Schema

- **`timestamp`**: ISO 8601 UTC string (e.g., `2023-11-20T12:00:00.000Z`)
- **`level`**: The log severity level (e.g., `info`, `error`)
- **`context`**: The module, class, or domain emitting the log (e.g., `EventProcessorService`)
- **`message`**: A concise, human-readable description of the event
- **`data`**: (Optional) Additional contextual structured metadata (e.g., `txHash`, `userId`, `contractId`)

### Example Production Log Entry

```json
{
"timestamp": "2023-11-20T12:00:05.123Z",
"level": "info",
"context": "HorizonService",
"message": "Successfully processed bridge transaction",
"data": {
"txHash": "0xabc123...",
"ledger": 456789
}
}
```

In non-production environments, logs may fallback to a human-readable, color-coded console output for better developer experience (DX).

## 3. Smart Contract Logging

Soroban smart contracts operate natively on-chain, where traditional `stdout` logging is not viable for production.

1. **Production Auditing (Events):** Use Soroban `#[contractevent]` structs as the primary mechanism for emitting structured logs. Events inherently provide structured, tamper-evident logging that off-chain indexers consume.
2. **Local Debugging:** Use `env.logs().print(...)` exclusively for local testing and development. These statements should ideally be removed or disabled in production deployments.

## 4. Best Practices

- **Do not log sensitive data:** PII (Personally Identifiable Information), private keys, secrets, and auth tokens MUST NOT be logged.
- **Include correlation IDs:** When processing user requests or multi-step asynchronous tasks, include a correlation identifier in the `data` field to trace the execution path.
- **Keep messages static:** The `message` field should be a static string (e.g., `"User login failed"`), while dynamic variables should be placed in the `data` object to facilitate aggregations and log metrics.
11 changes: 11 additions & 0 deletions docs/logger.module.ts
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 {}
101 changes: 101 additions & 0 deletions docs/logger.service.ts
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
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this.logLevel is misconfigured (not in levels), configuredIndex becomes -1, causing shouldLog to return true for all valid level values (unexpectedly enabling all logs). Add a fallback when configuredIndex === -1 (e.g., default to info), or validate/normalize the configured level at construction time.

Copilot uses AI. Check for mistakes.

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
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON.stringify(message) can throw on circular structures (and can also drop important info from Error objects). This can cause the logger itself to throw and potentially mask the original error. Consider a safe serialization approach (try/catch with a fallback string, or a safe-stringify/inspector-based formatter), and special-case Error to include name/message/stack.

Copilot uses AI. Check for mistakes.

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));
}
}
138 changes: 89 additions & 49 deletions recommendation-system/src/explainability/explainability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The helper introduces/propagates any for rankingSignal and a broad string for dominantSignal, which weakens type safety inside the method (e.g., property access on rankingSignal). Prefer using the same concrete type as the caller (e.g., a Types.RankingSignal-like interface) and narrow dominantSignal to a union/keyof type so invalid signal names are caught at compile time.

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

userProfile is typed as a required parameter (userProfile: Types.UserProfile), so this guard is inconsistent with the method signature and can hide upstream contract violations by silently returning an empty explanation. Either remove the guard (preferred if the type contract is correct) or make the parameter optional/nullable in the signature and document the behavior.

Suggested change
if (!userProfile) return ''; // Guard clause

Copilot uses AI. Check for mistakes.
const rules: string[] = [];

// Learning pattern rules
if (userProfile.behavior.pattern === Types.UserBehaviorPattern.FAST_TRACK) {
rules.push('You are a fast learner, so we prioritize advanced content');
} else if (userProfile.behavior.pattern === Types.UserBehaviorPattern.STRUGGLING) {
rules.push('We detected you need support in this area, recommending foundational content');
switch (userProfile.behavior.pattern) {
case Types.UserBehaviorPattern.FAST_TRACK:
rules.push('You are a fast learner, so we prioritize advanced content');
break;
case Types.UserBehaviorPattern.STRUGGLING:
rules.push('We detected you need support in this area, recommending foundational content');
break;
}

// Engagement rules
Expand Down Expand Up @@ -380,9 +412,17 @@ export class TransparencyDashboard {

for (const rec of recommendations) {
const modality = rec.metadata.modality;
if (modality === Types.ContentModality.VIDEO) videoCount++;
else if (modality === Types.ContentModality.TEXT) textCount++;
else if (modality === Types.ContentModality.INTERACTIVE) interactiveCount++;
switch (modality) {
case Types.ContentModality.VIDEO:
videoCount++;
break;
case Types.ContentModality.TEXT:
textCount++;
break;
case Types.ContentModality.INTERACTIVE:
interactiveCount++;
break;
}

const difficulty = rec.metadata.difficulty;
const diffKey = Object.keys(Types.DifficultyLevel)[difficulty - 1]?.toLowerCase() || 'unknown';
Expand Down
Loading