mirror of
https://github.com/immich-app/immich.git
synced 2025-06-16 21:38:28 +02:00
#0 init
This commit is contained in:
parent
fd46d43726
commit
5ec33bf9e3
4 changed files with 9148 additions and 0 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -23,3 +23,5 @@ mobile/android/fastlane/report.xml
|
|||
mobile/ios/fastlane/report.xml
|
||||
|
||||
vite.config.js.timestamp-*
|
||||
|
||||
.codespin/
|
342
server/src/services/smart-memory.service.spec.ts
Normal file
342
server/src/services/smart-memory.service.spec.ts
Normal 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)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
534
server/src/services/smart-memory.service.ts
Normal file
534
server/src/services/smart-memory.service.ts
Normal 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
8270
server/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue