Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
generateRecommendations,
generateUserPreferenceRecommendations,
generateHistoryBasedRecommendations,
applySpanishLanguagePenalty,
blendRecommendations,
} from './recommendationEngine'

const calculateTrackSimilarityMock = jest.fn()
Expand Down Expand Up @@ -448,3 +450,181 @@ describe('recommendationEngine', () => {
})
})
})

describe('applySpanishLanguagePenalty', () => {
test('rejects Spanish track in non-Spanish session', () => {
const recs = [
{
track: {
id: '1',
title: 'Reggaeton Éxito',
author: 'Latino',
metadata: {},
},
score: 0.8,
reasons: [],
},
]
const result = applySpanishLanguagePenalty(recs, false)
expect(result[0].score).toBe(-2.0)
})

test('keeps non-Spanish track', () => {
const recs = [
{
track: {
id: '1',
title: 'Rock Song',
author: 'Band',
metadata: {},
},
score: 0.8,
reasons: [],
},
]
const result = applySpanishLanguagePenalty(recs, false)
expect(result[0].score).toBe(0.8)
})

test('accepts Spanish in Spanish session', () => {
const recs = [
{
track: {
id: '1',
title: 'Reggaeton Éxito',
author: 'Latino',
metadata: {},
},
score: 0.8,
reasons: [],
},
]
const result = applySpanishLanguagePenalty(recs, true)
expect(result[0].score).toBe(0.8)
})

test('handles empty array', () => {
expect(applySpanishLanguagePenalty([], false)).toEqual([])
})

test('handles mixed batch', () => {
const recs = [
{
track: {
id: '1',
title: 'Reggaeton Éxito',
author: 'Latino',
metadata: {},
},
score: 0.8,
reasons: [],
},
{
track: {
id: '2',
title: 'Rock Song',
author: 'Band',
metadata: {},
},
score: 0.7,
reasons: [],
},
]
const result = applySpanishLanguagePenalty(recs, false)
expect(result[0].score).toBe(-2.0)
expect(result[1].score).toBe(0.7)
})
})

describe('blendRecommendations', () => {
const mockConfig: RecommendationConfig = {
maxRecommendations: 5,
diversityThreshold: 0.7,
prioritizeNewArtists: false,
}

test('returns empty array with no inputs', async () => {
applyDiversityFilterMock.mockImplementation((r) => r)
const result = await blendRecommendations([], [], [], mockConfig, [], false)
expect(result).toEqual([])
})

test('sorts by score descending', async () => {
applyDiversityFilterMock.mockImplementation((r) => r)
const primary = [
{
track: { id: '1' },
score: 0.5,
reasons: [],
},
{
track: { id: '2' },
score: 0.9,
reasons: [],
},
]
const result = await blendRecommendations(
primary,
[],
[],
mockConfig,
[],
false,
)
expect(result[0].score >= result[1].score).toBe(true)
})

test('respects max recommendations', async () => {
applyDiversityFilterMock.mockImplementation((r) => r)
const primary = Array.from({ length: 10 }, (_, i) => ({
track: { id: String(i) },
score: 0.5,
reasons: [],
}))
const result = await blendRecommendations(
primary,
[],
[],
mockConfig,
[],
false,
)
expect(result.length <= 5).toBe(true)
})

test('sorts by score after filtering', async () => {
applyDiversityFilterMock.mockImplementation((r) => r)
const primary = [
{ track: { id: '1' }, score: 0.5, reasons: [] },
{ track: { id: '2' }, score: 0.9, reasons: [] },
]
const result = await blendRecommendations(
primary,
[],
[],
mockConfig,
[],
false,
)
expect(result[0].score >= result[1].score).toBe(true)
})


test('handles negative scores', async () => {
applyDiversityFilterMock.mockImplementation((r) => r)
const primary = [
{ track: { id: '1' }, score: -0.5, reasons: [] },
{ track: { id: '2' }, score: 0.5, reasons: [] },
]
const result = await blendRecommendations(
primary,
[],
[],
mockConfig,
[],
false,
)
expect(result[0].score >= result[1].score).toBe(true)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@
applyDiversityFilter,
generateRecommendationReasons,
} from './recommendationHelpers'
import {
detectSpanishMarkers,
detectSessionLanguageMarkers,
} from '../../utils/music/languageHeuristics'

type TrackMetadata = {
tags?: string[]
popularity?: number
}

export async function generateRecommendations(
seedTrack: Track,
Expand Down Expand Up @@ -96,6 +105,15 @@
): Promise<RecommendationResult[]> {
try {
if (recentHistory.length === 0) return []

const sessionLanguageMarkers = detectSessionLanguageMarkers(
recentHistory.map((t) => ({
title: t.title,
author: t.author,
tags: (t.metadata as TrackMetadata)?.tags || [],
})),
)

const primarySeed = recentHistory[0]
const primaryRecommendations = await generateRecommendations(
primarySeed,
Expand All @@ -104,16 +122,22 @@
excludeTrackIds,
)

const filtered = applySpanishLanguagePenalty(
primaryRecommendations,
sessionLanguageMarkers.hasSpanish,
)

if (recentHistory.length > 1) {
return blendRecommendations(
primaryRecommendations,
filtered,
recentHistory.slice(1, 5),
availableTracks,
config,
excludeTrackIds,
sessionLanguageMarkers.hasSpanish,
)
}
return primaryRecommendations
return filtered
} catch (error) {
errorLog({
message: 'Error generating history-based recommendations:',
Expand All @@ -123,12 +147,54 @@
}
}

async function blendRecommendations(
export function applySpanishLanguagePenalty(
recommendations: RecommendationResult[],
hasSpanishMarkers: boolean,
): RecommendationResult[] {
return recommendations.map((rec) => {
const trackText = `${rec.track.title || ''} ${rec.track.author || ''}`
const candidateHasSpanish = detectSpanishMarkers(
trackText,
(rec.track.metadata as TrackMetadata)?.tags || [],
)

if (candidateHasSpanish && !hasSpanishMarkers) {
return {
...rec,
score: -2.0,

Check warning on line 164 in packages/bot/src/services/musicRecommendation/recommendationEngine.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Don't use a zero fraction in the number.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ2SMp5AkjvvgrR_LQWn&open=AZ2SMp5AkjvvgrR_LQWn&pullRequest=639
reasons: [
...rec.reasons,
'Rejected: Spanish track in non-Spanish session',
],
}
}

if (
candidateHasSpanish &&
!hasSpanishMarkers &&
((rec.track.metadata as TrackMetadata)?.popularity || 100) < 20
) {
return {
...rec,
score: rec.score - 0.4,
reasons: [
...rec.reasons,
'Low popularity + disjoint genre',
],
}
}

return rec
})
}

export async function blendRecommendations(
primaryRecommendations: RecommendationResult[],
additionalSeeds: Track[],
availableTracks: Track[],
config: RecommendationConfig,
excludeTrackIds: string[],
hasSpanishMarkers: boolean = false,
): Promise<RecommendationResult[]> {
const allRecommendations = new Map<string, RecommendationResult>()
for (const rec of primaryRecommendations) {
Expand All @@ -141,7 +207,8 @@
config,
excludeTrackIds,
)
for (const rec of seedRecs) {
const filtered = applySpanishLanguagePenalty(seedRecs, hasSpanishMarkers)
for (const rec of filtered) {
const key = rec.track.id || rec.track.url
const existing = allRecommendations.get(key)
if (existing) {
Expand Down
58 changes: 58 additions & 0 deletions packages/bot/src/spotify/spotifyApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getBatchAudioFeatures,
getArtistPopularity,
getSpotifyRecommendations,
getArtistGenres,
} from './spotifyApi'

type MockFetchResponse = {
Expand Down Expand Up @@ -576,3 +577,60 @@ describe('spotifyApi', () => {
})
})
})

describe('getArtistGenres', () => {
it('returns empty array when response not ok', async () => {
fetchMock.mockResolvedValue({ ok: false })
expect(await getArtistGenres('token', 'Artist')).toEqual([])
})

it('returns empty array on network error', async () => {
fetchMock.mockRejectedValue(new Error('Network'))
expect(await getArtistGenres('token', 'Artist')).toEqual([])
})

it('returns empty array when no artists found', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ artists: { items: [] } }),
})
expect(await getArtistGenres('token', 'UnknownArtist')).toEqual([])
})

it('returns empty array on JSON parse error', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => { throw new Error('Parse error') },
})
expect(await getArtistGenres('token', 'Artist')).toEqual([])
})

it('handles missing genres field', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
artists: { items: [{ name: 'Artist' }] },
}),
})
expect(await getArtistGenres('token', 'Artist')).toEqual([])
})

it('handles null genres', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({
artists: { items: [{ genres: null }] },
}),
})
expect(await getArtistGenres('token', 'Artist')).toEqual([])
})

it('handles undefined artists', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ artists: undefined }),
})
expect(await getArtistGenres('token', 'Artist')).toEqual([])
})
})
}
Loading
Loading