This commit is contained in:
gaurav-yadav 2025-03-08 18:10:40 +05:30
parent fd46d43726
commit 5ec33bf9e3
4 changed files with 9148 additions and 0 deletions

2
.gitignore vendored
View file

@ -23,3 +23,5 @@ mobile/android/fastlane/report.xml
mobile/ios/fastlane/report.xml
vite.config.js.timestamp-*
.codespin/

View file

@ -0,0 +1,342 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SmartMemoryService, ExtendedMemoryType } from './smart-memory.service';
import { JobName, JobStatus, QueueName } from '../enum';
import { ImmichWorker } from '../enum/worker.enum';
import { SystemMetadataKey } from '../entities/system-metadata.entity';
import { DateTime } from 'luxon';
import { SystemConfig } from 'src/config';
describe('SmartMemoryService', () => {
let service: SmartMemoryService;
let mockUserRepository;
let mockMemoryRepository;
let mockAssetRepository;
let mockPersonRepository;
let mockSearchRepository;
let mockSystemMetadataRepository;
let mockCronRepository;
let mockJobRepository;
let mockMachineLearningRepository;
let mockLogger;
let mockEventRepository;
const mockUser = { id: 'user1', email: 'test@example.com' };
const mockPerson = { id: 'person1', name: 'John Doe', faceAssetId: 'face1' };
const mockAsset = { id: 'asset1', originalPath: '/path/to/asset.jpg' };
const mockMemory = { id: 'memory1', type: ExtendedMemoryType.PERSON_COLLECTION };
const mockConfig = {
smartMemories: {
enabled: true,
cronExpression: '0 0 * * *',
enablePersonCollections: true,
enableLocationCollections: true,
enableThematicCollections: true,
},
machineLearning: {
enabled: true,
clip: {
modelName: 'ViT-B/32'
}
}
};
beforeEach(async () => {
// Create mock repositories
mockUserRepository = {
getList: jest.fn().mockResolvedValue([mockUser])
};
mockMemoryRepository = {
create: jest.fn().mockResolvedValue(mockMemory)
};
mockAssetRepository = {
getByPersonId: jest.fn().mockResolvedValue([mockAsset, mockAsset]),
getByPlace: jest.fn().mockResolvedValue([mockAsset, mockAsset]),
getByMultiplePersonIds: jest.fn().mockResolvedValue([mockAsset, mockAsset])
};
mockPersonRepository = {
getAllForUser: jest.fn().mockResolvedValue({ items: [mockPerson], hasNextPage: false })
};
mockSearchRepository = {
searchPlaces: jest.fn().mockResolvedValue([
{ city: 'New York', state: 'NY', country: 'USA', assetCount: 20 }
]),
searchSmart: jest.fn().mockResolvedValue({ items: [mockAsset, mockAsset], hasNextPage: true })
};
mockSystemMetadataRepository = {
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue(true)
};
mockCronRepository = {
create: jest.fn().mockReturnValue(true)
};
mockJobRepository = {
queue: jest.fn().mockResolvedValue(true)
};
mockMachineLearningRepository = {
encodeText: jest.fn().mockResolvedValue(new Float32Array(512))
};
mockLogger = {
log: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
verbose: jest.fn(),
debug: jest.fn(),
setContext: jest.fn()
};
mockEventRepository = {
emit: jest.fn().mockResolvedValue(true)
};
// Create the service with mocked dependencies
const module: TestingModule = await Test.createTestingModule({
providers: [
SmartMemoryService,
{ provide: 'UserRepository', useValue: mockUserRepository },
{ provide: 'MemoryRepository', useValue: mockMemoryRepository },
{ provide: 'AssetRepository', useValue: mockAssetRepository },
{ provide: 'PersonRepository', useValue: mockPersonRepository },
{ provide: 'SearchRepository', useValue: mockSearchRepository },
{ provide: 'SystemMetadataRepository', useValue: mockSystemMetadataRepository },
{ provide: 'CronRepository', useValue: mockCronRepository },
{ provide: 'JobRepository', useValue: mockJobRepository },
{ provide: 'MachineLearningRepository', useValue: mockMachineLearningRepository },
{ provide: 'EventRepository', useValue: mockEventRepository },
{ provide: 'LoggerService', useValue: mockLogger }
],
}).compile();
service = module.get<SmartMemoryService>(SmartMemoryService);
// Mock the getConfig method to return the known config
service.getConfig = jest.fn().mockResolvedValue(mockConfig);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('onConfigInit', () => {
it('should create cron job when smartMemories is enabled', async () => {
await service.onConfigInit({ newConfig: mockConfig });
// Verify the cron job was created with the correct parameters
expect(mockCronRepository.create).toHaveBeenCalledWith({
name: 'smart-memories-generation',
expression: mockConfig.smartMemories.cronExpression,
onTick: expect.any(Function), // Check that it's a function, but don't execute
start: true,
});
// Verify that the system metadata is initialized
expect(mockSystemMetadataRepository.set).toHaveBeenCalledWith(
SystemMetadataKey.SMART_MEMORIES_STATE,
expect.objectContaining({
lastGeneratedAt: null,
generationCount: 0,
stats: expect.any(Object)
})
);
});
it('should not create cron job when smartMemories is disabled', async () => {
await service.onConfigInit({
newConfig: { ...mockConfig, smartMemories: { ...mockConfig.smartMemories, enabled: false } }
});
expect(mockCronRepository.create).not.toHaveBeenCalled();
});
});
describe('handleCreateSmartMemories', () => {
it('should process all users and update stats', async () => {
// Mock the internal functions to control their behavior during the test
service['generateMemoriesForUser'] = jest.fn().mockResolvedValue({
personCollections: 2,
peopleGroups: 1,
locationCollections: 3,
themeCollections: 2,
discoveredThemes: 1
});
const result = await service.handleCreateSmartMemories();
expect(result).toBe(JobStatus.SUCCESS);
expect(mockUserRepository.getList).toHaveBeenCalledWith({ withDeleted: false });
expect(service['generateMemoriesForUser']).toHaveBeenCalledWith(mockUser.id);
expect(mockSystemMetadataRepository.set).toHaveBeenCalledWith(
SystemMetadataKey.SMART_MEMORIES_STATE,
expect.objectContaining({
lastGeneratedAt: expect.any(String),
generationCount: 1,
stats: expect.objectContaining({
personCollections: 2,
peopleGroups: 1,
locationCollections: 3,
themeCollections: 2,
discoveredThemes: 1
})
})
);
});
it('should return failed status when error occurs', async () => {
// Force an error by rejecting the getList mock
mockUserRepository.getList.mockRejectedValue(new Error('Test error'));
const result = await service.handleCreateSmartMemories();
expect(result).toBe(JobStatus.FAILED);
expect(mockLogger.error).toHaveBeenCalled();
});
});
describe('generatePersonCollections', () => {
beforeEach(() => {
// Mock internal methods
service['findCoOccurringPeople'] = jest.fn().mockResolvedValue([
{ id: 'person2', name: 'Jane Doe' },
{ id: 'person3', name: 'Bob Smith' }
]);
});
it('should create person collections and group collections', async () => {
const result = await service['generatePersonCollections'](mockUser.id);
expect(result).toEqual({
personCollections: 1,
peopleGroups: 1
});
expect(mockPersonRepository.getAllForUser).toHaveBeenCalledWith(
expect.any(Object),
mockUser.id,
expect.objectContaining({ minimumFaceCount: 10, withHidden: false })
);
expect(mockAssetRepository.getByPersonId).toHaveBeenCalledWith(
mockUser.id,
mockPerson.id,
expect.any(Object)
);
expect(mockMemoryRepository.create).toHaveBeenCalledTimes(2);
expect(mockMemoryRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
ownerId: mockUser.id,
type: ExtendedMemoryType.PERSON_COLLECTION,
data: expect.objectContaining({
personId: mockPerson.id,
personName: mockPerson.name
})
}),
expect.any(Set)
);
});
});
describe('generateLocationCollections', () => {
beforeEach(() => {
// Mock internal methods
service['getSignificantLocations'] = jest.fn().mockResolvedValue([
{ city: 'New York', state: 'NY', country: 'USA', assetCount: 20 }
]);
service['formatLocationName'] = jest.fn().mockReturnValue('New York, NY');
});
it('should create location collections', async () => {
const result = await service['generateLocationCollections'](mockUser.id);
expect(result).toBe(1);
expect(service['getSignificantLocations']).toHaveBeenCalledWith(mockUser.id);
expect(mockAssetRepository.getByPlace).toHaveBeenCalledWith(
mockUser.id,
expect.objectContaining({
city: 'New York',
state: 'NY',
country: 'USA'
})
);
expect(mockMemoryRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
ownerId: mockUser.id,
type: ExtendedMemoryType.LOCATION_COLLECTION,
data: expect.objectContaining({
city: 'New York',
state: 'NY',
country: 'USA',
locationName: 'New York, NY'
})
}),
expect.any(Set)
);
});
});
describe('generateThematicCollections', () => {
beforeEach(() => {
// Mock internal methods
service['getDiscoveredFacets'] = jest.fn().mockResolvedValue([
{ id: 'facet1', name: 'Beach Days', confidence: 0.75, prompt: 'beach, ocean' }
]);
service['getFacetAssets'] = jest.fn().mockResolvedValue([mockAsset, mockAsset]);
});
it('should create theme collections and discovered themes', async () => {
const result = await service['generateThematicCollections'](mockUser.id, mockConfig.machineLearning);
expect(result).toEqual({
themeCollections: 5, // One for each predefined theme
discoveredThemes: 1
});
// Check that CLIP encodings were created
expect(mockMachineLearningRepository.encodeText).toHaveBeenCalledTimes(5);
// Check that smart search was performed
expect(mockSearchRepository.searchSmart).toHaveBeenCalledTimes(5);
// Check that memories were created
expect(mockMemoryRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
ownerId: mockUser.id,
type: ExtendedMemoryType.THEME_COLLECTION,
data: expect.objectContaining({
theme: expect.any(String)
})
}),
expect.any(Set)
);
// Check discovered themes
expect(service['getDiscoveredFacets']).toHaveBeenCalledWith(mockUser.id);
expect(service['getFacetAssets']).toHaveBeenCalledWith(mockUser.id, expect.any(Object));
expect(mockMemoryRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
ownerId: mockUser.id,
type: ExtendedMemoryType.DISCOVERED_THEME,
data: expect.objectContaining({
theme: 'Beach Days',
confidence: 0.75
})
}),
expect.any(Set)
);
});
});
});

View file

@ -0,0 +1,534 @@
import { Injectable } from '@nestjs/common';
import { OnEvent, OnJob } from '@nestjs/event-emitter';
import { DateTime } from 'luxon';
import { JobName, JobStatus, QueueName } from '../enum';
import { ImmichWorker } from '../constants/worker.constant';
import { MemoryType } from '../entities/memory.entity';
import { BaseService } from './base.service';
import { isSmartSearchEnabled } from '../utils/machine-learning.util';
import { ArgOf } from '../repositories/event.repository';
import { handlePromiseError } from '../utils/error.util';
import { SystemMetadataKey } from '../entities/system-metadata.entity';
import { CronExpression } from '@nestjs/schedule';
// Extend the MemoryType enum with new types (if not already in the existing code)
// This would normally be in a separate file and imported
export enum ExtendedMemoryType {
PERSON_COLLECTION = 'person_collection',
PEOPLE_GROUP = 'people_group',
LOCATION_COLLECTION = 'location_collection',
THEME_COLLECTION = 'theme_collection',
DISCOVERED_THEME = 'discovered_theme',
EVENT = 'event',
SEASONAL = 'seasonal'
}
// Structure for location-based memory
interface LocationMemoryData {
city?: string;
state?: string;
country?: string;
locationName?: string;
}
// Structure for person-based memory
interface PersonMemoryData {
personId: string;
personName: string;
relatedPeople?: Array<{ id: string; name: string }>;
}
// Structure for theme-based memory
interface ThemeMemoryData {
theme: string;
confidence?: number;
discoveredPrompt?: string;
}
@Injectable()
export class SmartMemoryService extends BaseService {
/**
* Initialize smart memories on system startup
*/
@OnEvent({ name: 'app.bootstrap' })
async onAppBootstrap() {
this.logger.log('Initializing Smart Memory Service');
}
/**
* Set up recurring jobs based on system configuration
*/
@OnEvent({ name: 'config.init', workers: [ImmichWorker.MICROSERVICES] })
async onConfigInit({ newConfig }: ArgOf<'config.init'>) {
// This retrieves data from SystemConfig to make this configurable for admins in Web App
const { smartMemories } = newConfig;
if (smartMemories?.enabled) {
// Create a daily cron job for smart memory generation
this.cronRepository.create({
name: 'smart-memories-generation',
// This is expression that the admin provides in the admin settings for when smart memories are generated
expression: smartMemories.cronExpression || CronExpression.EVERY_DAY_AT_MIDNIGHT,
onTick: () => handlePromiseError(
this.jobRepository.queue({ name: JobName.SMART_MEMORIES_CREATE }),
this.logger
),
start: true,
});
} else {
this.logger.log('Smart Memory generation is disabled in configuration');
}
// Check for existing smart memory state
const state = await this.systemMetadataRepository.get(SystemMetadataKey.SMART_MEMORIES_STATE);
if (!state) {
// Initialize state on first run
await this.systemMetadataRepository.set(SystemMetadataKey.SMART_MEMORIES_STATE, {
lastGeneratedAt: null,
generationCount: 0,
stats: {
personCollections: 0,
peopleGroups: 0,
locationCollections: 0,
themeCollections: 0,
discoveredThemes: 0,
events: 0,
seasonal: 0
}
});
}
}
/**
* Handle configuration updates
*/
@OnEvent({ name: 'config.update', server: true })
async onConfigUpdate({ newConfig }: ArgOf<'config.update'>) {
await this.onConfigInit({ newConfig });
}
/**
* Main job handler for smart memory creation
*/
@OnJob({ name: JobName.SMART_MEMORIES_CREATE, queue: QueueName.BACKGROUND_TASK })
async handleCreateSmartMemories(): Promise<JobStatus> {
this.logger.log('Starting Smart Memory generation');
const stats = {
personCollections: 0,
peopleGroups: 0,
locationCollections: 0,
themeCollections: 0,
discoveredThemes: 0,
events: 0,
seasonal: 0
};
try {
// Get all active users
const users = await this.userRepository.getList({ withDeleted: false });
this.logger.log(`Generating smart memories for ${users.length} users`);
// Process each user sequentially to avoid overloading the system
for (const user of users) {
const userStats = await this.generateMemoriesForUser(user.id);
// Accumulate stats
for (const key in userStats) {
if (Object.prototype.hasOwnProperty.call(stats, key)) {
stats[key] += userStats[key];
}
}
}
// Update system metadata with generation stats
const state = await this.systemMetadataRepository.get(SystemMetadataKey.SMART_MEMORIES_STATE) || {};
await this.systemMetadataRepository.set(SystemMetadataKey.SMART_MEMORIES_STATE, {
...state,
lastGeneratedAt: new Date().toISOString(),
generationCount: (state.generationCount || 0) + 1,
stats
});
this.logger.log(`Smart Memory generation completed: created ${Object.values(stats).reduce((a, b) => a + b, 0)} memories`);
return JobStatus.SUCCESS;
} catch (error) {
this.logger.error(`Error during Smart Memory generation: ${error}`, error?.stack);
return JobStatus.FAILED;
}
}
/**
* Generate all types of memories for a single user
*/
private async generateMemoriesForUser(userId: string) {
const stats = {
personCollections: 0,
peopleGroups: 0,
locationCollections: 0,
themeCollections: 0,
discoveredThemes: 0,
events: 0,
seasonal: 0
};
try {
// Get configuration settings from system settings that the admin can modify
const { smartMemories, machineLearning } = await this.getConfig({ withCache: true });
// Generate person-based collections if enabled
if (smartMemories.enablePersonCollections !== false) {
const personResults = await this.generatePersonCollections(userId);
stats.personCollections += personResults.personCollections;
stats.peopleGroups += personResults.peopleGroups;
}
// Generate location-based collections if enabled
if (smartMemories.enableLocationCollections !== false) {
stats.locationCollections += await this.generateLocationCollections(userId);
}
// Generate theme-based collections if enabled and ML is available
if (smartMemories.enableThematicCollections !== false && isSmartSearchEnabled(machineLearning)) {
const themeResults = await this.generateThematicCollections(userId, machineLearning);
stats.themeCollections += themeResults.themeCollections;
stats.discoveredThemes += themeResults.discoveredThemes;
}
return stats;
} catch (error) {
this.logger.error(`Error generating memories for user ${userId}: ${error}`, error?.stack);
return stats;
}
}
/**
* Creates collections based on people who frequently appear together
*/
private async generatePersonCollections(userId: string): Promise<{ personCollections: number; peopleGroups: number }> {
let personCollections = 0;
let peopleGroups = 0;
try {
// Get significant people for this user with minimum face count which are then displayed for the users
const { items: people } = await this.personRepository.getAllForUser(
{ take: 20, skip: 0 },
userId,
{ minimumFaceCount: 10, withHidden: false }
);
for (const person of people) {
if (!person.name) continue;
// Get assets where this person appears
const assets = await this.assetRepository.getByPersonId(userId, person.id, { limit: 30 });
if (assets.length < 10) continue;
// Create individual person collection
await this.memoryRepository.create({
ownerId: userId,
type: ExtendedMemoryType.PERSON_COLLECTION,
data: {
personId: person.id,
personName: person.name
} as PersonMemoryData,
memoryAt: new Date().toISOString(),
showAt: DateTime.utc().startOf('day').toISO(),
hideAt: DateTime.utc().plus({ days: 7 }).endOf('day').toISO(),
}, new Set(assets.map(asset => asset.id)));
personCollections++;
// Find co-occurring people in the same photos
const coOccurrences = await this.findCoOccurringPeople(userId, person.id);
if (coOccurrences.length >= 2) {
// Get assets containing both the primary person and co-occurring people
const groupAssets = await this.assetRepository.getByMultiplePersonIds(
userId,
[person.id, ...coOccurrences.map(p => p.id)],
{ minPersonCount: 2, limit: 20 }
);
if (groupAssets.length >= 5) {
// Create group memory with these people
await this.memoryRepository.create({
ownerId: userId,
type: ExtendedMemoryType.PEOPLE_GROUP,
data: {
personId: person.id,
personName: person.name,
relatedPeople: coOccurrences.map(p => ({ id: p.id, name: p.name }))
} as PersonMemoryData,
memoryAt: new Date().toISOString(),
showAt: DateTime.utc().startOf('day').toISO(),
hideAt: DateTime.utc().plus({ days: 7 }).endOf('day').toISO(),
}, new Set(groupAssets.map(asset => asset.id)));
peopleGroups++;
}
}
}
} catch (error) {
this.logger.error(`Error generating person collections: ${error}`, error?.stack);
}
return { personCollections, peopleGroups };
}
/**
* Helper method to find people who frequently appear with a specific person
*/
private async findCoOccurringPeople(userId: string, personId: string): Promise<Array<{ id: string; name: string }>> {
// This would ideally be implemented in the person repository
// For now, we'll simulate the implementation with this function
try {
// Get assets where the specified person appears
const assets = await this.assetRepository.getByPersonId(userId, personId, { limit: 100 });
// Find other people who appear in these assets
const personCounts = new Map<string, { count: number; name: string }>();
for (const asset of assets) {
// In a real implementation, you would have a way to get all persons in an asset
// For now, assuming we can get this from the asset.faces property
if (asset.faces && Array.isArray(asset.faces)) {
for (const face of asset.faces) {
if (face.personId && face.personId !== personId) {
const person = face.person || { id: face.personId, name: '' };
if (person.name) {
const current = personCounts.get(person.id) || { count: 0, name: person.name };
current.count += 1;
personCounts.set(person.id, current);
}
}
}
}
}
// Filter to get people who appear in at least 5 photos with the specified person
return Array.from(personCounts.entries())
.filter(([_, data]) => data.count >= 5)
.map(([id, data]) => ({ id, name: data.name }))
.slice(0, 5); // Limit to top 5 co-occurring people
} catch (error) {
this.logger.error(`Error finding co-occurring people: ${error}`, error?.stack);
return [];
}
}
/**
* Creates collections based on significant locations
*/
private async generateLocationCollections(userId: string): Promise<number> {
let locationCollections = 0;
try {
// Find locations with significant number of photos
const locations = await this.getSignificantLocations(userId);
for (const location of locations) {
if (location.assetCount < 15) continue;
// Get assets for this location
const assets = await this.assetRepository.getByPlace(userId, {
city: location.city,
state: location.state,
country: location.country
});
// Generate a location name
const locationName = this.formatLocationName(location);
// Create a memory for this trip/location
await this.memoryRepository.create({
ownerId: userId,
type: ExtendedMemoryType.LOCATION_COLLECTION,
data: {
city: location.city,
state: location.state,
country: location.country,
locationName
} as LocationMemoryData,
memoryAt: new Date().toISOString(),
showAt: DateTime.utc().startOf('day').toISO(),
hideAt: DateTime.utc().plus({ days: 7 }).endOf('day').toISO(),
}, new Set(assets.map(asset => asset.id)));
locationCollections++;
}
} catch (error) {
this.logger.error(`Error generating location collections: ${error}`, error?.stack);
}
return locationCollections;
}
/**
* Helper method to find significant locations in a user's library
*/
private async getSignificantLocations(userId: string) {
// This would ideally be implemented in the search repository
// For now, we'll simulate the implementation with this function
try {
// Query for places with count of assets
const locations = await this.searchRepository.searchPlaces('');
// Filter for places that have a city, state, or country
return locations
.filter(location => {
return (location.city || location.state || location.country) && location.assetCount >= 15;
})
.slice(0, 10); // Limit to top 10 locations
} catch (error) {
this.logger.error(`Error getting significant locations: ${error}`, error?.stack);
return [];
}
}
/**
* Format location data into a readable name
*/
private formatLocationName(location: { city?: string; state?: string; country?: string }): string {
const parts = [];
if (location.city) parts.push(location.city);
if (location.state && location.state !== location.city) parts.push(location.state);
if (location.country && parts.length === 0) parts.push(location.country);
return parts.join(', ');
}
/**
* Creates thematic collections using CLIP embeddings and clustering
*/
private async generateThematicCollections(userId: string, machineLearning: any):
Promise<{ themeCollections: number; discoveredThemes: number }> {
let themeCollections = 0;
let discoveredThemes = 0;
try {
// 1. Predefined themes with prompts
const themes = [
{ name: "Nature", prompt: "beautiful nature, landscapes, outdoors, forests, mountains, lakes" },
{ name: "Food", prompt: "delicious food, meals, cooking, restaurants, dining" },
{ name: "Architecture", prompt: "buildings, architecture, structures, monuments, cityscape" },
{ name: "Pets", prompt: "cute pets, dogs, cats, animals, furry friends" },
{ name: "Travel", prompt: "travel, vacations, journeys, tourism, sightseeing" }
];
for (const theme of themes) {
// Generate embedding for theme prompt
const embedding = await this.machineLearningRepository.encodeText(
machineLearning.urls,
theme.prompt,
machineLearning.clip
);
// Search for matching assets
const matches = await this.searchRepository.searchSmart(
{ page: 1, size: 30 },
{ userIds: [userId], embedding, minScore: 0.25 }
);
if (matches.items.length >= 15) {
// Create thematic memory
await this.memoryRepository.create({
ownerId: userId,
type: ExtendedMemoryType.THEME_COLLECTION,
data: {
theme: theme.name,
confidence: 0.8 // Predefined themes have high confidence
} as ThemeMemoryData,
memoryAt: new Date().toISOString(),
showAt: DateTime.utc().startOf('day').toISO(),
hideAt: DateTime.utc().plus({ days: 7 }).endOf('day').toISO(),
}, new Set(matches.items.map(asset => asset.id)));
themeCollections++;
}
}
// 2. Discovered themes using facet clustering
const facets = await this.getDiscoveredFacets(userId);
for (const facet of facets) {
// Get relevant assets for this auto-discovered theme
const assets = await this.getFacetAssets(userId, facet);
if (assets.length >= 10) {
// Create dynamic thematic memory
await this.memoryRepository.create({
ownerId: userId,
type: ExtendedMemoryType.DISCOVERED_THEME,
data: {
theme: facet.name,
confidence: facet.confidence,
discoveredPrompt: facet.prompt
} as ThemeMemoryData,
memoryAt: new Date().toISOString(),
showAt: DateTime.utc().startOf('day').toISO(),
hideAt: DateTime.utc().plus({ days: 7 }).endOf('day').toISO(),
}, new Set(assets.map(asset => asset.id)));
discoveredThemes++;
}
}
} catch (error) {
this.logger.error(`Error generating thematic collections: ${error}`, error?.stack);
}
return { themeCollections, discoveredThemes };
}
/**
* Discover facets/clusters in a user's library
*/
private async getDiscoveredFacets(userId: string): Promise<Array<{ id: string; name: string; confidence: number; prompt: string }>> {
// This would ideally be implemented in the search repository
// For now, we'll simulate the implementation with this function
try {
const discoveredThemes = [
{ id: '1', name: 'Beach Days', confidence: 0.75, prompt: 'beach, ocean, sand, waves, seaside' },
{ id: '2', name: 'City Life', confidence: 0.7, prompt: 'urban, city, streets, downtown, buildings' },
{ id: '3', name: 'Sunset Moments', confidence: 0.8, prompt: 'sunset, golden hour, dusk, evening sky' }
];
return discoveredThemes;
} catch (error) {
this.logger.error(`Error discovering facets: ${error}`, error?.stack);
return [];
}
}
/**
* Get assets for a discovered facet/cluster
*/
private async getFacetAssets(userId: string, facet: { id: string; prompt: string }) {
try {
// In a real implementation, you might have a more efficient way to get these assets
// For now, we'll use smart search with the facet prompt
const { machineLearning } = await this.getConfig({ withCache: true });
const embedding = await this.machineLearningRepository.encodeText(
machineLearning.urls,
facet.prompt,
machineLearning.clip
);
const matches = await this.searchRepository.searchSmart(
{ page: 1, size: 20 },
{ userIds: [userId], embedding, minScore: 0.3 }
);
return matches.items;
} catch (error) {
this.logger.error(`Error getting facet assets: ${error}`, error?.stack);
return [];
}
}
}

8270
server/yarn.lock Normal file

File diff suppressed because it is too large Load diff