Skip to content

Commit 96db8dc

Browse files
authored
Merge pull request #444 from Juwonlo/impact
Implemented automated changelog generation
2 parents 3fa7305 + 3a55767 commit 96db8dc

4 files changed

Lines changed: 299 additions & 47 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: Generate Changelog
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
workflow_dispatch:
8+
inputs:
9+
version:
10+
description: 'Version to generate changelog for (e.g., v1.0.0)'
11+
required: false
12+
13+
jobs:
14+
changelog:
15+
runs-on: ubuntu-latest
16+
steps:
17+
- name: Checkout code
18+
uses: actions/checkout@v3
19+
with:
20+
fetch-depth: 0
21+
22+
- name: Setup Node.js
23+
uses: actions/setup-node@v3
24+
with:
25+
node-version: '18'
26+
27+
- name: Generate Changelog
28+
run: node scripts/generate-changelog.js
29+
30+
- name: Commit Changelog
31+
run: |
32+
git config --local user.email "action@github.com"
33+
git config --local user.name "GitHub Action"
34+
git add CHANGELOG.md
35+
git commit -m "docs(changelog): update for release [skip ci]" || echo "No changes to commit"
36+
git push origin HEAD:main

recommendation-system/src/explainability/explainability.ts

Lines changed: 87 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,90 @@ import * as Types from '../types';
1414
// EXPLANATION GENERATOR
1515
// ============================================================================
1616

17+
export interface ExplanationStrategy {
18+
matches(dominantSignal: string): boolean;
19+
extract(
20+
rankingSignal: any,
21+
userProfile: Types.UserProfile,
22+
similarUsers?: string[]
23+
): {
24+
primaryReason: string;
25+
supportingSignals: string[];
26+
featureAttribution: Array<{ feature: string; importance: number; contribution: string }>;
27+
};
28+
}
29+
30+
export class CollaborativeSignalStrategy implements ExplanationStrategy {
31+
matches(signal: string): boolean { return signal === 'collaborativeSignal'; }
32+
extract(rankingSignal: any, _userProfile: Types.UserProfile, similarUsers?: string[]) {
33+
const result = { primaryReason: '', supportingSignals: [] as string[], featureAttribution: [] as any[] };
34+
if (similarUsers && similarUsers.length > 0) {
35+
result.primaryReason = `Users like you enjoyed this content`;
36+
result.supportingSignals.push(`Liked by ${similarUsers.length} similar learners`);
37+
result.featureAttribution.push({
38+
feature: 'user_similarity',
39+
importance: rankingSignal.collaborativeSignal,
40+
contribution: `Based on similar learning patterns`,
41+
});
42+
}
43+
return result;
44+
}
45+
}
46+
47+
export class ContentSignalStrategy implements ExplanationStrategy {
48+
matches(signal: string): boolean { return signal === 'contentSignal'; }
49+
extract(rankingSignal: any, userProfile: Types.UserProfile) {
50+
const result = { primaryReason: '', supportingSignals: [] as string[], featureAttribution: [] as any[] };
51+
result.primaryReason = `Matches your interests`;
52+
const topics = Array.from(userProfile.features.topicAffinities.keys()).slice(0, 2);
53+
result.supportingSignals.push(`Related to your interest in ${topics.join(' and ')}`);
54+
result.featureAttribution.push({
55+
feature: 'topic_match',
56+
importance: rankingSignal.contentSignal,
57+
contribution: `Content topic alignment with your profile`,
58+
});
59+
return result;
60+
}
61+
}
62+
63+
export class LearningPathSignalStrategy implements ExplanationStrategy {
64+
matches(signal: string): boolean { return signal === 'learningPathSignal'; }
65+
extract(rankingSignal: any) {
66+
return {
67+
primaryReason: `Recommended based on your learning path`,
68+
supportingSignals: [`Prerequisite for your next goal`],
69+
featureAttribution: [{
70+
feature: 'learning_path_fit',
71+
importance: rankingSignal.learningPathSignal,
72+
contribution: `Aligns with recommended progression`,
73+
}]
74+
};
75+
}
76+
}
77+
78+
export class QualitySignalStrategy implements ExplanationStrategy {
79+
matches(signal: string): boolean { return signal === 'qualitySignal'; }
80+
extract(rankingSignal: any) {
81+
return {
82+
primaryReason: `High-quality content`,
83+
supportingSignals: [`Highly rated by other learners`],
84+
featureAttribution: [{
85+
feature: 'content_quality',
86+
importance: rankingSignal.qualitySignal,
87+
contribution: `Strong engagement and completion metrics`,
88+
}]
89+
};
90+
}
91+
}
92+
1793
export class ExplanationGenerator {
94+
private strategies: ExplanationStrategy[] = [
95+
new CollaborativeSignalStrategy(),
96+
new ContentSignalStrategy(),
97+
new LearningPathSignalStrategy(),
98+
new QualitySignalStrategy(),
99+
];
100+
18101
/**
19102
* Generate explanation for a recommendation
20103
*/
@@ -82,54 +165,11 @@ export class ExplanationGenerator {
82165
supportingSignals: string[];
83166
featureAttribution: Array<{ feature: string; importance: number; contribution: string }>;
84167
} {
85-
const result = {
86-
primaryReason: '',
87-
supportingSignals: [] as string[],
88-
featureAttribution: [] as Array<{ feature: string; importance: number; contribution: string }>,
89-
};
90-
91-
switch (dominantSignal) {
92-
case 'collaborativeSignal':
93-
if (!similarUsers || similarUsers.length === 0) break; // Guard clause
94-
result.primaryReason = `Users like you enjoyed this content`;
95-
result.supportingSignals.push(`Liked by ${similarUsers.length} similar learners`);
96-
result.featureAttribution.push({
97-
feature: 'user_similarity',
98-
importance: rankingSignal.collaborativeSignal,
99-
contribution: `Based on similar learning patterns`,
100-
});
101-
break;
102-
case 'contentSignal':
103-
result.primaryReason = `Matches your interests`;
104-
const topics = Array.from(userProfile.features.topicAffinities.keys()).slice(0, 2);
105-
result.supportingSignals.push(`Related to your interest in ${topics.join(' and ')}`);
106-
result.featureAttribution.push({
107-
feature: 'topic_match',
108-
importance: rankingSignal.contentSignal,
109-
contribution: `Content topic alignment with your profile`,
110-
});
111-
break;
112-
case 'learningPathSignal':
113-
result.primaryReason = `Recommended based on your learning path`;
114-
result.supportingSignals.push(`Prerequisite for your next goal`);
115-
result.featureAttribution.push({
116-
feature: 'learning_path_fit',
117-
importance: rankingSignal.learningPathSignal,
118-
contribution: `Aligns with recommended progression`,
119-
});
120-
break;
121-
case 'qualitySignal':
122-
result.primaryReason = `High-quality content`;
123-
result.supportingSignals.push(`Highly rated by other learners`);
124-
result.featureAttribution.push({
125-
feature: 'content_quality',
126-
importance: rankingSignal.qualitySignal,
127-
contribution: `Strong engagement and completion metrics`,
128-
});
129-
break;
168+
const strategy = this.strategies.find(s => s.matches(dominantSignal));
169+
if (strategy) {
170+
return strategy.extract(rankingSignal, userProfile, similarUsers);
130171
}
131-
132-
return result;
172+
return { primaryReason: '', supportingSignals: [], featureAttribution: [] };
133173
}
134174

135175
/**

recommendation-system/src/privacy/privacy.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,49 @@ export class DataMinimizer {
421421
// PRIVACY COMPLIANCE MANAGER
422422
// ============================================================================
423423

424+
export class PrivacyComplianceManagerBuilder {
425+
private anonymizer?: UserAnonymizer;
426+
private dpEngine?: DifferentialPrivacyEngine;
427+
private optOutManager?: OptOutManager;
428+
private piiFilter?: PIIFilter;
429+
private dataMinimizer?: DataMinimizer;
430+
431+
withAnonymizer(anonymizer: UserAnonymizer): this {
432+
this.anonymizer = anonymizer;
433+
return this;
434+
}
435+
436+
withDifferentialPrivacy(dpEngine: DifferentialPrivacyEngine): this {
437+
this.dpEngine = dpEngine;
438+
return this;
439+
}
440+
441+
withOptOutManager(optOutManager: OptOutManager): this {
442+
this.optOutManager = optOutManager;
443+
return this;
444+
}
445+
446+
withPIIFilter(piiFilter: PIIFilter): this {
447+
this.piiFilter = piiFilter;
448+
return this;
449+
}
450+
451+
withDataMinimizer(dataMinimizer: DataMinimizer): this {
452+
this.dataMinimizer = dataMinimizer;
453+
return this;
454+
}
455+
456+
build(): PrivacyComplianceManager {
457+
return new PrivacyComplianceManager(
458+
this.anonymizer,
459+
this.dpEngine,
460+
this.optOutManager,
461+
this.piiFilter,
462+
this.dataMinimizer
463+
);
464+
}
465+
}
466+
424467
export class PrivacyComplianceManager {
425468
private anonymizer: UserAnonymizer;
426469
private dpEngine: DifferentialPrivacyEngine;

scripts/generate-changelog.js

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Automated Changelog Generator
5+
*
6+
* Parses conventional commit messages, categorizes changes,
7+
* tracks versions based on git tags, and generates release notes.
8+
*/
9+
10+
const { execSync } = require('child_process');
11+
const fs = require('fs');
12+
const path = require('path');
13+
14+
function runCommand(command) {
15+
try {
16+
return execSync(command, { encoding: 'utf-8' }).trim();
17+
} catch (error) {
18+
return '';
19+
}
20+
}
21+
22+
function generateChangelog() {
23+
console.log('Generating changelog...');
24+
25+
// Get tags sorted by semantic version
26+
const tagsOutput = runCommand('git tag --sort=-v:refname');
27+
const tags = tagsOutput.split('\n').filter(Boolean);
28+
29+
let currentVersion = 'vUnreleased';
30+
let previousTag = '';
31+
let gitLogCmd = 'git log --format="%H|%s"';
32+
33+
if (tags.length > 0) {
34+
currentVersion = tags[0];
35+
if (tags.length > 1) {
36+
previousTag = tags[1];
37+
gitLogCmd = `git log ${previousTag}..${currentVersion} --format="%H|%s"`;
38+
} else {
39+
gitLogCmd = `git log ${currentVersion} --format="%H|%s"`;
40+
}
41+
}
42+
43+
const logOutput = runCommand(gitLogCmd);
44+
if (!logOutput) {
45+
console.log('No commits found for the current range.');
46+
return;
47+
}
48+
49+
const commits = logOutput.split('\n').filter(Boolean);
50+
51+
// Categories for release notes
52+
const categories = {
53+
feat: { title: '🚀 Features', items: [] },
54+
fix: { title: '🐛 Bug Fixes', items: [] },
55+
security: { title: '🔒 Security', items: [] },
56+
perf: { title: '⚡ Performance', items: [] },
57+
refactor: { title: '♻️ Refactoring', items: [] },
58+
docs: { title: '📚 Documentation', items: [] },
59+
test: { title: '🧪 Testing', items: [] },
60+
chore: { title: '🔧 Chores & Maintenance', items: [] },
61+
};
62+
63+
const commitRegex = /^(feat|fix|docs|style|refactor|perf|test|chore|security)(?:\(([^)]+)\))?:\s*(.+)$/i;
64+
65+
commits.forEach(commitLine => {
66+
const parts = commitLine.split('|');
67+
if (parts.length < 2) return;
68+
const hash = parts[0];
69+
const subject = parts.slice(1).join('|');
70+
71+
const match = subject.match(commitRegex);
72+
if (match) {
73+
const type = match[1].toLowerCase();
74+
const scope = match[2];
75+
const message = match[3];
76+
77+
if (categories[type]) {
78+
const shortHash = hash.substring(0, 7);
79+
const scopeStr = scope ? `**${scope}:** ` : '';
80+
categories[type].items.push(`- ${scopeStr}${message} (${shortHash})`);
81+
}
82+
}
83+
});
84+
85+
const date = new Date().toISOString().split('T')[0];
86+
let releaseNotes = `## [${currentVersion}] - ${date}\n\n`;
87+
88+
if (previousTag) {
89+
releaseNotes += `Compare with ${previousTag}\n\n`;
90+
}
91+
92+
let hasChanges = false;
93+
for (const key in categories) {
94+
if (categories[key].items.length > 0) {
95+
hasChanges = true;
96+
releaseNotes += `### ${categories[key].title}\n\n`;
97+
releaseNotes += categories[key].items.join('\n') + '\n\n';
98+
}
99+
}
100+
101+
if (!hasChanges) {
102+
releaseNotes += 'No significant changes in this release.\n\n';
103+
}
104+
105+
const changelogPath = path.join(__dirname, '..', 'CHANGELOG.md');
106+
let currentChangelog = '';
107+
108+
if (fs.existsSync(changelogPath)) {
109+
currentChangelog = fs.readFileSync(changelogPath, 'utf8');
110+
} else {
111+
currentChangelog = '# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n';
112+
}
113+
114+
// Prevent duplicate entries for the same version
115+
if (currentChangelog.includes(`## [${currentVersion}]`)) {
116+
console.log(`Version ${currentVersion} already exists in CHANGELOG.md`);
117+
return;
118+
}
119+
120+
// Insert release notes after the main header
121+
const headerMatch = currentChangelog.match(/^# Changelog[^\n]*\n+/i);
122+
if (headerMatch) {
123+
const header = headerMatch[0];
124+
const rest = currentChangelog.substring(header.length);
125+
fs.writeFileSync(changelogPath, header + releaseNotes + rest);
126+
} else {
127+
fs.writeFileSync(changelogPath, '# Changelog\n\n' + releaseNotes + currentChangelog);
128+
}
129+
130+
console.log(`Successfully generated release notes for ${currentVersion}`);
131+
}
132+
133+
generateChangelog();

0 commit comments

Comments
 (0)