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
5 changes: 1 addition & 4 deletions src/timeline/services/foryou/for-you.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,9 @@ export class ForyouService {
cursor?: string, // Keep for API compatibility but not used
limit: number = 20
): Promise<{
// data: ScoredCandidateDTO[];
data: TweetResponseDTO[];
pagination: { next_cursor: string | null; has_more: boolean };
}> {
// Get or create cursor for this user
let timeline_cursor = await this.timeline_cursor_repository.findOne({
where: { user_id },
});
Expand Down Expand Up @@ -66,7 +64,6 @@ export class ForyouService {
);

// Fallback: Fetch tweets directly from candidates service
// This handles the case where frontend calls immediately after assigning interests
const candidates = await this.timeline_candidates_service.getCandidates(
user_id,
new Set(), // No exclusions for fresh start
Expand All @@ -88,7 +85,7 @@ export class ForyouService {
);
return {
data: fallback_tweets,
pagination: { next_cursor: null, has_more: false },
pagination: { next_cursor: 'next', has_more: true },
};
}

Expand Down
24 changes: 24 additions & 0 deletions src/timeline/services/timeline-candidates.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ import { Repository } from 'typeorm';
import { UserInterests } from 'src/user/entities/user-interests.entity';
import { TweetCategory } from 'src/tweets/entities/tweet-category.entity';
import { Tweet } from 'src/tweets/entities/tweet.entity';
import { Category } from 'src/category/entities/category.entity';
import { InitTimelineQueueJobService } from 'src/background-jobs/timeline/timeline.service';

describe('TimelineCandidatesService', () => {
let service: TimelineCandidatesService;
let user_interests_repository: jest.Mocked<Repository<UserInterests>>;
let tweet_category_repository: jest.Mocked<Repository<TweetCategory>>;
let tweet_repository: jest.Mocked<Repository<Tweet>>;
let category_repository: jest.Mocked<Repository<Category>>;
let config_service: jest.Mocked<ConfigService>;
let init_timeline_queue_job_service: jest.Mocked<InitTimelineQueueJobService>;

const mock_user_id = 'user-123';
const mock_user_interests = [
Expand Down Expand Up @@ -48,6 +52,8 @@ describe('TimelineCandidatesService', () => {
provide: getRepositoryToken(UserInterests),
useValue: {
find: jest.fn(),
create: jest.fn(),
save: jest.fn(),
},
},
{
Expand All @@ -62,6 +68,22 @@ describe('TimelineCandidatesService', () => {
createQueryBuilder: jest.fn(),
},
},
{
provide: getRepositoryToken(Category),
useValue: {
createQueryBuilder: jest.fn(() => ({
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([]),
})),
},
},
{
provide: InitTimelineQueueJobService,
useValue: {
queueInitTimelineQueue: jest.fn().mockResolvedValue(undefined),
},
},
{
provide: ConfigService,
useValue: {
Expand All @@ -78,6 +100,8 @@ describe('TimelineCandidatesService', () => {
user_interests_repository = module.get(getRepositoryToken(UserInterests));
tweet_category_repository = module.get(getRepositoryToken(TweetCategory));
tweet_repository = module.get(getRepositoryToken(Tweet));
category_repository = module.get(getRepositoryToken(Category));
init_timeline_queue_job_service = module.get(InitTimelineQueueJobService);
config_service = module.get(ConfigService);
});

Expand Down
59 changes: 44 additions & 15 deletions src/timeline/services/timeline-candidates.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { Repository } from 'typeorm';
import { UserInterests } from 'src/user/entities/user-interests.entity';
import { TweetCategory } from 'src/tweets/entities/tweet-category.entity';
import { Tweet } from 'src/tweets/entities/tweet.entity';
import { Category } from 'src/category/entities/category.entity';
import { InitTimelineQueueJobService } from 'src/background-jobs/timeline/timeline.service';
import { JOB_DELAYS } from 'src/background-jobs/constants/queue.constants';

export interface ICandidateTweet {
tweet_id: string;
Expand All @@ -25,40 +28,36 @@ export class TimelineCandidatesService {
private readonly tweet_category_repository: Repository<TweetCategory>,
@InjectRepository(Tweet)
private readonly tweet_repository: Repository<Tweet>,
private readonly config_service: ConfigService
@InjectRepository(Category)
private readonly category_repository: Repository<Category>,
private readonly config_service: ConfigService,
private readonly init_timeline_queue_job_service: InitTimelineQueueJobService
) {
this.tweet_freshness_days = this.config_service.get<number>(
'TIMELINE_TWEET_FRESHNESS_DAYS',
7
);

this.LIMIT_FACTOR = 500; // Factor to over-fetch for filtering
this.LIMIT_FACTOR = 500;
}

/**
* Get candidate tweets based on user's interests
* @param user_id User ID
* @param excluded_tweet_ids Tweet IDs to exclude (already seen)
* @param limit Maximum number of candidates to return
* @returns Array of candidate tweets
*/
async getCandidates(
user_id: string,
excluded_tweet_ids: Set<string>,
limit: number
): Promise<ICandidateTweet[]> {
// console.log(
// `[Candidates] Getting ${limit} candidates for user ${user_id}, excluding ${excluded_tweet_ids.size} tweets`
// );
const user_interests = await this.user_interests_repository.find({
where: { user_id },
order: { score: 'DESC' },
});
// console.log(`[Candidates] Found ${user_interests.length} interests for user ${user_id}`);

if (user_interests.length === 0) {
console.log(`[Candidates] No interests found, using random fallback`);
// Fallback: Get random fresh tweets if user has no interests
console.log(`[Candidates] No interests found, assigning 3 random interests`);
// No interests means that the user makes a refresh before inserting their interests
// Assign 3 random interests and trigger the init timeline queue job
await this.assignRandomInterests(user_id);
await this.init_timeline_queue_job_service.queueInitTimelineQueue({ user_id });
// for now, return random tweets while the background job processes
return this.getRandomFreshTweets(user_id, excluded_tweet_ids, limit);
}

Expand Down Expand Up @@ -299,4 +298,34 @@ export class TimelineCandidatesService {

return candidates;
}

private async assignRandomInterests(user_id: string): Promise<void> {
try {
const random_categories = await this.category_repository
.createQueryBuilder('category')
.orderBy('RANDOM()')
.limit(3)
.getMany();

if (random_categories.length === 0) {
console.error(`[Candidates] No categories available to assign`);
return;
}

const user_interests = random_categories.map((category) =>
this.user_interests_repository.create({
user_id,
category_id: String(category.id),
score: 100,
})
);

await this.user_interests_repository.save(user_interests);
} catch (error) {
console.error(
`[Candidates] Error assigning random interests to user ${user_id}:`,
error
);
}
}
}
2 changes: 2 additions & 0 deletions src/timeline/timeline.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { TimelineRedisService } from './services/timeline-redis.service';
import { TimelineCandidatesService } from './services/timeline-candidates.service';
import { BackgroundJobsModule } from 'src/background-jobs/background-jobs.module';
import { RedisModuleConfig } from 'src/redis/redis.module';
import { Category } from 'src/category/entities';

@Module({
imports: [
Expand All @@ -26,6 +27,7 @@ import { RedisModuleConfig } from 'src/redis/redis.module';
TweetCategory,
UserInterests,
UserTimelineCursor,
Category,
]),
forwardRef(() => BackgroundJobsModule),
RedisModuleConfig,
Expand Down