feat(classification): ✨ Add classification logic, controller endpoints, and imajin client integration for media tagging; update module/configurations and include test coverage
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
d436628f81
commit
42809e6487
7 changed files with 0 additions and 667 deletions
|
|
@ -1,157 +0,0 @@
|
|||
import { Controller, Get, Post, Query, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Queue } from 'bullmq';
|
||||
import { In, IsNull, Not, Repository } from 'typeorm';
|
||||
|
||||
import { createLogger } from '@/common';
|
||||
import { PhotoEntity } from '@/entities';
|
||||
|
||||
const BACKFILL_BATCH_SIZE = 50;
|
||||
|
||||
@ApiTags('classification')
|
||||
@Controller('api/admin/classification')
|
||||
export class ClassificationController {
|
||||
private readonly logger = createLogger(ClassificationController.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PhotoEntity)
|
||||
private readonly photoRepository: Repository<PhotoEntity>,
|
||||
@InjectQueue('photo-classification')
|
||||
private readonly classificationQueue: Queue,
|
||||
) {}
|
||||
|
||||
@Post('backfill')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Enqueue photos for classification. Use ?force=true to re-classify already-completed photos.',
|
||||
})
|
||||
@ApiQuery({ name: 'force', required: false, type: Boolean, description: 'Reset completed photos to pending first' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Backfill enqueued successfully',
|
||||
schema: { type: 'object', properties: { enqueued: { type: 'number' } } },
|
||||
})
|
||||
async backfill(@Query('force') force?: string): Promise<{ enqueued: number }> {
|
||||
// When force=true, reset already-completed photos to pending so the improved
|
||||
// classification logic (semantic selfie inference) is applied to all photos.
|
||||
if (force === 'true') {
|
||||
await this.photoRepository.update(
|
||||
{
|
||||
classificationStatus: In(['completed', 'skipped']),
|
||||
storageKey: Not(IsNull()),
|
||||
},
|
||||
{ classificationStatus: 'pending' },
|
||||
);
|
||||
this.logger.logWithData('info', 'Force backfill: reset completed/skipped photos to pending');
|
||||
}
|
||||
|
||||
let enqueued = 0;
|
||||
let offset = 0;
|
||||
|
||||
this.logger.logWithData('info', 'Starting classification backfill', { force: force === 'true' });
|
||||
|
||||
for (;;) {
|
||||
const batch = await this.photoRepository.find({
|
||||
where: {
|
||||
classificationStatus: 'pending',
|
||||
storageKey: Not(IsNull()),
|
||||
},
|
||||
select: ['id', 'storageKey', 'isScreenshot', 'isSelfie', 'mimeType'],
|
||||
take: BACKFILL_BATCH_SIZE,
|
||||
skip: offset,
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
|
||||
if (batch.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const jobs = batch.map((photo) => ({
|
||||
name: 'classify',
|
||||
data: {
|
||||
photoId: photo.id,
|
||||
storageKey: photo.storageKey ?? null,
|
||||
isScreenshot: photo.isScreenshot,
|
||||
isSelfie: photo.isSelfie,
|
||||
mimeType: photo.mimeType ?? null,
|
||||
},
|
||||
}));
|
||||
|
||||
await this.classificationQueue.addBulk(jobs);
|
||||
enqueued += batch.length;
|
||||
offset += batch.length;
|
||||
|
||||
this.logger.logWithData('info', 'Enqueued classification batch', {
|
||||
batchSize: batch.length,
|
||||
totalEnqueued: enqueued,
|
||||
});
|
||||
|
||||
if (batch.length < BACKFILL_BATCH_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.logWithData('info', 'Classification backfill complete', { enqueued });
|
||||
|
||||
return { enqueued };
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
@ApiOperation({ summary: 'Get classification statistics grouped by status and category' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Classification statistics',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
byStatus: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
classificationStatus: { type: 'string' },
|
||||
count: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
byCategory: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
category: { type: 'string', nullable: true },
|
||||
count: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
async stats(): Promise<{
|
||||
byStatus: Array<{ classificationStatus: string; count: string }>;
|
||||
byCategory: Array<{ category: string | null; count: string }>;
|
||||
}> {
|
||||
const [byStatus, byCategory] = await Promise.all([
|
||||
this.photoRepository
|
||||
.createQueryBuilder('photo')
|
||||
.select('photo.classificationStatus', 'classificationStatus')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy('photo.classificationStatus')
|
||||
.orderBy('count', 'DESC')
|
||||
.getRawMany<{ classificationStatus: string; count: string }>(),
|
||||
|
||||
this.photoRepository
|
||||
.createQueryBuilder('photo')
|
||||
.select('photo.category', 'category')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('photo.classificationStatus = :status', { status: 'completed' })
|
||||
.groupBy('photo.category')
|
||||
.orderBy('count', 'DESC')
|
||||
.getRawMany<{ category: string | null; count: string }>(),
|
||||
]);
|
||||
|
||||
return { byStatus, byCategory };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { determineCategory } from './classification.logic';
|
||||
import { PhotoCategory } from './classification.types';
|
||||
|
||||
import type { ModeratorResult } from './classification.types';
|
||||
|
||||
describe('determineCategory — screenshot path', () => {
|
||||
const screenshotPhoto = { isScreenshot: true, isSelfie: false, storageKey: 'some/key.jpg' };
|
||||
|
||||
it('returns SCREENSHOT_OTHER for any screenshot', () => {
|
||||
expect(determineCategory(screenshotPhoto)).toBe(PhotoCategory.SCREENSHOT_OTHER);
|
||||
});
|
||||
|
||||
it('returns SCREENSHOT_OTHER even with a moderatorResult', () => {
|
||||
const moderator: ModeratorResult = { nsfw_score: 0.9, nsfw_category: 'explicit', face_count: 2, safe: false, risk_score: 0.9, flags: [], requires_human_review: false, estimated_age: null, hash_match: null };
|
||||
expect(determineCategory(screenshotPhoto, moderator)).toBe(PhotoCategory.SCREENSHOT_OTHER);
|
||||
});
|
||||
});
|
||||
|
||||
describe('determineCategory — no storage key', () => {
|
||||
it('returns UNCLASSIFIED when storageKey is null and not a screenshot', () => {
|
||||
expect(determineCategory({ isScreenshot: false, isSelfie: false, storageKey: null })).toBe(
|
||||
PhotoCategory.UNCLASSIFIED,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns UNCLASSIFIED when storageKey is undefined and not a screenshot', () => {
|
||||
expect(determineCategory({ isScreenshot: false, isSelfie: false })).toBe(PhotoCategory.UNCLASSIFIED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('determineCategory — moderator result path', () => {
|
||||
const photo = { isScreenshot: false, isSelfie: false, storageKey: 'photos/original.jpg' };
|
||||
const selfiePhoto = { isScreenshot: false, isSelfie: true, storageKey: 'photos/selfie.jpg' };
|
||||
|
||||
it('returns UNCLASSIFIED when no moderatorResult is provided', () => {
|
||||
expect(determineCategory(photo)).toBe(PhotoCategory.UNCLASSIFIED);
|
||||
});
|
||||
|
||||
it('returns SELF_EXPLICIT for explicit + selfie', () => {
|
||||
const moderator: ModeratorResult = { nsfw_score: 0.95, nsfw_category: 'explicit', face_count: 1, safe: false, risk_score: 0.95, flags: [], requires_human_review: false, estimated_age: null, hash_match: null };
|
||||
expect(determineCategory(selfiePhoto, moderator)).toBe(PhotoCategory.SELF_EXPLICIT);
|
||||
});
|
||||
|
||||
it('returns SELF_EXPLICIT for explicit + face_count <= 1 even without isSelfie flag', () => {
|
||||
const moderator: ModeratorResult = { nsfw_score: 0.95, nsfw_category: 'explicit', face_count: 1, safe: false, risk_score: 0.95, flags: [], requires_human_review: false, estimated_age: null, hash_match: null };
|
||||
expect(determineCategory(photo, moderator)).toBe(PhotoCategory.SELF_EXPLICIT);
|
||||
});
|
||||
|
||||
it('returns SELF_WITH_OTHERS for explicit + face_count > 1 without isSelfie', () => {
|
||||
const moderator: ModeratorResult = { nsfw_score: 0.95, nsfw_category: 'explicit', face_count: 3, safe: false, risk_score: 0.95, flags: [], requires_human_review: false, estimated_age: null, hash_match: null };
|
||||
expect(determineCategory(photo, moderator)).toBe(PhotoCategory.SELF_WITH_OTHERS);
|
||||
});
|
||||
|
||||
it('returns SELF_EXPLICIT for explicit + isSelfie even with face_count > 1', () => {
|
||||
const moderator: ModeratorResult = { nsfw_score: 0.95, nsfw_category: 'explicit', face_count: 2, safe: false, risk_score: 0.95, flags: [], requires_human_review: false, estimated_age: null, hash_match: null };
|
||||
expect(determineCategory(selfiePhoto, moderator)).toBe(PhotoCategory.SELF_EXPLICIT);
|
||||
});
|
||||
|
||||
it('returns SELF_NUDE when nsfw_category is nude', () => {
|
||||
const moderator: ModeratorResult = { nsfw_score: 0.8, nsfw_category: 'nude', face_count: 0, safe: false, risk_score: 0.8, flags: [], requires_human_review: false, estimated_age: null, hash_match: null };
|
||||
expect(determineCategory(photo, moderator)).toBe(PhotoCategory.SELF_NUDE);
|
||||
});
|
||||
|
||||
it('returns SELF_NUDE when nsfw_category is suggestive', () => {
|
||||
const moderator: ModeratorResult = { nsfw_score: 0.6, nsfw_category: 'suggestive', face_count: 0, safe: true, risk_score: 0.6, flags: [], requires_human_review: false, estimated_age: null, hash_match: null };
|
||||
expect(determineCategory(photo, moderator)).toBe(PhotoCategory.SELF_NUDE);
|
||||
});
|
||||
|
||||
it('returns SELF_WITH_OTHERS when nsfw_category is nude and face_count > 1', () => {
|
||||
const moderator: ModeratorResult = { nsfw_score: 0.8, nsfw_category: 'nude', face_count: 2, safe: false, risk_score: 0.8, flags: [], requires_human_review: false, estimated_age: null, hash_match: null };
|
||||
expect(determineCategory(photo, moderator)).toBe(PhotoCategory.SELF_WITH_OTHERS);
|
||||
});
|
||||
|
||||
it('returns SELF_CLOTHED when isSelfie and nsfw_category is normal', () => {
|
||||
// nsfw_score can be very high (0.9993) for normal images — it is confidence, not NSFW level
|
||||
const moderator: ModeratorResult = { nsfw_score: 0.9993, nsfw_category: 'normal', face_count: 1, safe: true, risk_score: 0.01, flags: [], requires_human_review: false, estimated_age: null, hash_match: null };
|
||||
expect(determineCategory(selfiePhoto, moderator)).toBe(PhotoCategory.SELF_CLOTHED);
|
||||
});
|
||||
|
||||
it('returns FRIENDS when face_count > 1 and not selfie and nsfw_category is normal', () => {
|
||||
const moderator: ModeratorResult = { nsfw_score: 0.99, nsfw_category: 'normal', face_count: 2, safe: true, risk_score: 0.01, flags: [], requires_human_review: false, estimated_age: null, hash_match: null };
|
||||
expect(determineCategory(photo, moderator)).toBe(PhotoCategory.FRIENDS);
|
||||
});
|
||||
|
||||
it('returns UNCLASSIFIED when normal, no selfie, no faces', () => {
|
||||
const moderator: ModeratorResult = { nsfw_score: 0.99, nsfw_category: 'normal', face_count: 0, safe: true, risk_score: 0.01, flags: [], requires_human_review: false, estimated_age: null, hash_match: null };
|
||||
expect(determineCategory(photo, moderator)).toBe(PhotoCategory.UNCLASSIFIED);
|
||||
});
|
||||
|
||||
it('returns SELF_CLOTHED when nsfw_category is null and isSelfie', () => {
|
||||
const moderator: ModeratorResult = { nsfw_score: null, nsfw_category: null, face_count: 1, safe: true, risk_score: 0, flags: [], requires_human_review: false, estimated_age: null, hash_match: null };
|
||||
expect(determineCategory(selfiePhoto, moderator)).toBe(PhotoCategory.SELF_CLOTHED);
|
||||
});
|
||||
|
||||
it('treats null face_count as 0 (no faces)', () => {
|
||||
const moderator: ModeratorResult = { nsfw_score: 0.99, nsfw_category: 'normal', face_count: null, safe: true, risk_score: 0.01, flags: [], requires_human_review: false, estimated_age: null, hash_match: null };
|
||||
expect(determineCategory(selfiePhoto, moderator)).toBe(PhotoCategory.SELF_CLOTHED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('determineCategory — semantic selfie inference', () => {
|
||||
const photo = { isScreenshot: false, isSelfie: false, storageKey: 'photos/photo.jpg' };
|
||||
const normalModerator: ModeratorResult = { nsfw_score: 0.99, nsfw_category: 'normal', face_count: 1, safe: true, risk_score: 0.01, flags: [], requires_human_review: false, estimated_age: null, hash_match: null };
|
||||
|
||||
it('infers selfie via selfie_style > 0.25 with face_count=1', () => {
|
||||
expect(
|
||||
determineCategory(photo, normalModerator, { selfie_style: 0.3 }),
|
||||
).toBe(PhotoCategory.SELF_CLOTHED);
|
||||
});
|
||||
|
||||
it('falls back to single-face → SELF_CLOTHED when selfie_style is below threshold', () => {
|
||||
// face_count=1 with low selfie_style still counts as selfie (single face = self-portrait)
|
||||
expect(
|
||||
determineCategory(photo, normalModerator, { selfie_style: 0.1 }),
|
||||
).toBe(PhotoCategory.SELF_CLOTHED);
|
||||
});
|
||||
|
||||
it('returns FRIENDS when face_count > 1 without selfie signal', () => {
|
||||
const twoFaces: ModeratorResult = { ...normalModerator, face_count: 2 };
|
||||
expect(
|
||||
determineCategory(photo, twoFaces, { selfie_style: 0.1 }),
|
||||
).toBe(PhotoCategory.FRIENDS);
|
||||
});
|
||||
|
||||
it('does not infer selfie via selfie_style when face_count != 1', () => {
|
||||
// selfie_style only triggers inference when face_count === 1
|
||||
const twoFaces: ModeratorResult = { ...normalModerator, face_count: 2 };
|
||||
expect(
|
||||
determineCategory(photo, twoFaces, { selfie_style: 0.9 }),
|
||||
).toBe(PhotoCategory.FRIENDS);
|
||||
});
|
||||
|
||||
it('accepts null semanticTags gracefully', () => {
|
||||
expect(determineCategory(photo, normalModerator, null)).toBe(PhotoCategory.SELF_CLOTHED);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import { PhotoCategory } from './classification.types';
|
||||
|
||||
import type { ModeratorResult } from './classification.types';
|
||||
|
||||
interface PhotoClassificationInput {
|
||||
isScreenshot: boolean;
|
||||
isSelfie: boolean;
|
||||
storageKey?: string | null;
|
||||
}
|
||||
|
||||
export function determineCategory(
|
||||
photo: PhotoClassificationInput,
|
||||
moderatorResult?: ModeratorResult,
|
||||
semanticTags?: Record<string, number> | null,
|
||||
): PhotoCategory {
|
||||
// Screenshots cannot be sub-classified — imajin-semantic only has creator/model filters.
|
||||
if (photo.isScreenshot) {
|
||||
return PhotoCategory.SCREENSHOT_OTHER;
|
||||
}
|
||||
|
||||
if (!photo.storageKey) {
|
||||
return PhotoCategory.UNCLASSIFIED;
|
||||
}
|
||||
|
||||
if (moderatorResult) {
|
||||
const faceCount = moderatorResult.face_count ?? 0;
|
||||
const category = moderatorResult.nsfw_category;
|
||||
|
||||
// Infer selfie: iOS Photos.app flag OR single face with selfie_style semantic signal.
|
||||
// selfie_style comes from imajin-semantic (SigLIP zero-shot); threshold 0.25 chosen to
|
||||
// avoid false positives on environmental single-face shots (street photography, etc).
|
||||
const isSelfie =
|
||||
photo.isSelfie ||
|
||||
(faceCount === 1 && (semanticTags?.selfie_style ?? 0) > 0.25);
|
||||
|
||||
// nsfw_score is the classifier's CONFIDENCE in the detected category,
|
||||
// not a "how NSFW" score. Classification must use nsfw_category exclusively.
|
||||
if (category === 'explicit') {
|
||||
return isSelfie || faceCount <= 1
|
||||
? PhotoCategory.SELF_EXPLICIT
|
||||
: PhotoCategory.SELF_WITH_OTHERS;
|
||||
}
|
||||
|
||||
if (category === 'nude' || category === 'suggestive') {
|
||||
return faceCount > 1 ? PhotoCategory.SELF_WITH_OTHERS : PhotoCategory.SELF_NUDE;
|
||||
}
|
||||
|
||||
// category === 'normal' or null — use heuristics
|
||||
if (isSelfie) {
|
||||
return PhotoCategory.SELF_CLOTHED;
|
||||
}
|
||||
|
||||
if (faceCount > 1) {
|
||||
return PhotoCategory.FRIENDS;
|
||||
}
|
||||
|
||||
// Single face without selfie semantic signal — still a selfie (self-portrait)
|
||||
if (faceCount === 1) {
|
||||
return PhotoCategory.SELF_CLOTHED;
|
||||
}
|
||||
|
||||
return PhotoCategory.UNCLASSIFIED;
|
||||
}
|
||||
|
||||
return PhotoCategory.UNCLASSIFIED;
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { MinioModule } from '@/common/minio';
|
||||
import { PhotoEntity } from '@/entities';
|
||||
|
||||
import { ClassificationController } from './classification.controller';
|
||||
import { ClassificationProcessor } from './classification.processor';
|
||||
import { ImajinClient } from './imajin.client';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([PhotoEntity]),
|
||||
BullModule.registerQueue(
|
||||
{
|
||||
name: 'photo-classification',
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 5000 },
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 500,
|
||||
},
|
||||
},
|
||||
{ name: 'face-extraction' },
|
||||
),
|
||||
MinioModule.forEnv({ defaultBucket: 'media-gallery' }),
|
||||
],
|
||||
controllers: [ClassificationController],
|
||||
providers: [ClassificationProcessor, ImajinClient],
|
||||
exports: [ImajinClient],
|
||||
})
|
||||
export class ClassificationModule {}
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
import { InjectQueue, Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Job, Queue } from 'bullmq';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { createLogger } from '@/common';
|
||||
import { MinioService } from '@/common/minio';
|
||||
import { PhotoEntity } from '@/entities';
|
||||
|
||||
import { determineCategory } from './classification.logic';
|
||||
import { SEMANTIC_CONTENT_FILTERS } from './classification.types';
|
||||
import { ImajinClient } from './imajin.client';
|
||||
|
||||
interface ClassificationJobData {
|
||||
photoId: string;
|
||||
storageKey: string | null;
|
||||
isScreenshot: boolean;
|
||||
isSelfie: boolean;
|
||||
mimeType: string | null;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
// imajin-moderator is rate-limited at 30 req/min — cap at 25 with a duration buffer
|
||||
@Processor('photo-classification', { concurrency: 2, limiter: { max: 25, duration: 60000 } })
|
||||
export class ClassificationProcessor extends WorkerHost {
|
||||
private readonly logger = createLogger(ClassificationProcessor.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PhotoEntity)
|
||||
private readonly photoRepository: Repository<PhotoEntity>,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly imajinClient: ImajinClient,
|
||||
@InjectQueue('face-extraction')
|
||||
private readonly faceExtractionQueue: Queue,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<ClassificationJobData>): Promise<void> {
|
||||
const { photoId, storageKey, isScreenshot, isSelfie, mimeType, userId } = job.data;
|
||||
|
||||
this.logger.logWithData('info', 'Processing classification job', {
|
||||
photoId,
|
||||
storageKey,
|
||||
isScreenshot,
|
||||
isSelfie,
|
||||
attempt: job.attemptsMade + 1,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.photoRepository.update(photoId, {
|
||||
classificationStatus: 'processing',
|
||||
});
|
||||
|
||||
if (!storageKey || mimeType?.startsWith('video/')) {
|
||||
this.logger.logWithData('info', 'Skipping classification — no storage key or video file', {
|
||||
photoId,
|
||||
reason: !storageKey ? 'no_storage_key' : 'video_file',
|
||||
});
|
||||
|
||||
await this.photoRepository.update(photoId, {
|
||||
classificationStatus: 'skipped',
|
||||
category: null,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Screenshots are classified as SCREENSHOT_OTHER — imajin-semantic only has creator/model
|
||||
// filters (femboy, lingerie, etc.) and cannot classify screenshot sub-types.
|
||||
if (isScreenshot) {
|
||||
await this.photoRepository.update(photoId, {
|
||||
category: determineCategory({ isScreenshot: true, isSelfie, storageKey }),
|
||||
classificationStatus: 'completed',
|
||||
});
|
||||
|
||||
this.logger.logWithData('info', 'Screenshot classified', { photoId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Download image from MinIO and send as base64 — both imajin services block private IPs
|
||||
// via SSRF protection, so image_url cannot be used in dev (localhost / 10.x.x.x).
|
||||
const presignedUrl = await this.minioService.getDownloadUrl(storageKey, 3600);
|
||||
const imageResponse = await fetch(presignedUrl);
|
||||
|
||||
if (imageResponse.status === 404) {
|
||||
// File not yet uploaded to MinIO — skip gracefully
|
||||
this.logger.logWithData('info', 'Image not in MinIO — skipping', { photoId, storageKey });
|
||||
|
||||
await this.photoRepository.update(photoId, {
|
||||
classificationStatus: 'skipped',
|
||||
category: null,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!imageResponse.ok) {
|
||||
throw new Error(`Failed to fetch image from MinIO: ${imageResponse.status} ${imageResponse.statusText}`);
|
||||
}
|
||||
|
||||
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
||||
const mimeTypeForBase64 = mimeType ?? 'image/jpeg';
|
||||
// moderator expects full data URI; siglip expects raw base64
|
||||
const imageDataUri = `data:${mimeTypeForBase64};base64,${imageBuffer.toString('base64')}`;
|
||||
const imageRawBase64 = imageBuffer.toString('base64');
|
||||
|
||||
// Run moderator (mandatory) and siglip (supplementary) in parallel.
|
||||
// Siglip failure is non-fatal — moderator is the primary classification signal.
|
||||
const [moderatorResult, siglipResult] = await Promise.all([
|
||||
this.imajinClient.classifyWithModerator(imageDataUri),
|
||||
this.imajinClient
|
||||
.classifyWithSiglip(imageRawBase64, [...SEMANTIC_CONTENT_FILTERS])
|
||||
.catch((error: unknown) => {
|
||||
this.logger.logWithData('warn', 'Siglip classification failed — semantic tags will be null', {
|
||||
photoId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
|
||||
const category = determineCategory(
|
||||
{ isScreenshot, isSelfie, storageKey },
|
||||
moderatorResult,
|
||||
siglipResult?.detected_attributes ?? null,
|
||||
);
|
||||
|
||||
await this.photoRepository.update(photoId, {
|
||||
category,
|
||||
classificationStatus: 'completed',
|
||||
semanticTags: siglipResult?.detected_attributes ?? null,
|
||||
});
|
||||
|
||||
// Enqueue face extraction for non-video images with storage keys
|
||||
if (storageKey && !mimeType?.startsWith('video/')) {
|
||||
await this.faceExtractionQueue.add(
|
||||
'extract',
|
||||
{
|
||||
photoId,
|
||||
storageKey,
|
||||
userId,
|
||||
mimeType,
|
||||
},
|
||||
{
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 2000 },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.logWithData('info', 'Classification completed', {
|
||||
photoId,
|
||||
category,
|
||||
semanticTagCount: siglipResult ? Object.keys(siglipResult.detected_attributes).length : 0,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
this.logger.logWithData('error', 'Classification failed', {
|
||||
photoId,
|
||||
error: errorMessage,
|
||||
attempt: job.attemptsMade + 1,
|
||||
});
|
||||
|
||||
await this.photoRepository.update(photoId, {
|
||||
classificationStatus: 'failed',
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
export enum PhotoCategory {
|
||||
SCREENSHOT_RECEIPT = 'screenshot_receipt',
|
||||
SCREENSHOT_CONVERSATION = 'screenshot_conversation',
|
||||
SCREENSHOT_SHOPPING = 'screenshot_shopping',
|
||||
SCREENSHOT_MEME = 'screenshot_meme',
|
||||
SCREENSHOT_EVENT = 'screenshot_event',
|
||||
SCREENSHOT_RESERVATION = 'screenshot_reservation',
|
||||
SCREENSHOT_HOTTIE = 'screenshot_hottie',
|
||||
SCREENSHOT_OTHER = 'screenshot_other',
|
||||
SELF_CLOTHED = 'self_clothed',
|
||||
SELF_NUDE = 'self_nude',
|
||||
SELF_EXPLICIT = 'self_explicit',
|
||||
SELF_WITH_OTHERS = 'self_with_others',
|
||||
FRIENDS = 'friends',
|
||||
UNCLASSIFIED = 'unclassified',
|
||||
}
|
||||
|
||||
export enum ClassificationStatus {
|
||||
PENDING = 'pending',
|
||||
PROCESSING = 'processing',
|
||||
COMPLETED = 'completed',
|
||||
FAILED = 'failed',
|
||||
SKIPPED = 'skipped',
|
||||
}
|
||||
|
||||
export interface ModeratorResult {
|
||||
safe: boolean;
|
||||
risk_score: number;
|
||||
flags: string[];
|
||||
requires_human_review: boolean;
|
||||
// "normal" = safe; Falconsai/nsfw_image_detection model categories
|
||||
nsfw_score: number | null;
|
||||
nsfw_category: 'normal' | 'suggestive' | 'nude' | 'explicit' | null;
|
||||
estimated_age: number | null;
|
||||
face_count: number | null;
|
||||
hash_match: string | null;
|
||||
}
|
||||
|
||||
export interface SiglipResult {
|
||||
detected_attributes: Record<string, number>;
|
||||
style_scores: Record<string, number>;
|
||||
alignment_score: number;
|
||||
top_attributes: [string, number][];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters passed to imajin-semantic for zero-shot attribute detection.
|
||||
* These are creator/model content attributes — NOT screenshot classifiers
|
||||
* (imajin-semantic cannot classify screenshot sub-types).
|
||||
*/
|
||||
export const SEMANTIC_CONTENT_FILTERS = [
|
||||
'lingerie',
|
||||
'bikini',
|
||||
'nude',
|
||||
'explicit_content',
|
||||
'bdsm',
|
||||
'femboy',
|
||||
'cosplay',
|
||||
'fitness',
|
||||
'casual',
|
||||
'professional_shoot',
|
||||
'outdoor',
|
||||
'selfie_style',
|
||||
'couple',
|
||||
'group',
|
||||
] as const;
|
||||
|
||||
export type SemanticContentFilter = (typeof SEMANTIC_CONTENT_FILTERS)[number];
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { HttpModelBossClient } from '@lilith/model-boss';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { createLogger, HTTP_MODEL_BOSS_CLIENT } from '@/common';
|
||||
|
||||
import type { ModeratorResult, SiglipResult } from './classification.types';
|
||||
|
||||
@Injectable()
|
||||
export class ImajinClient {
|
||||
private readonly logger = createLogger(ImajinClient.name);
|
||||
|
||||
constructor(
|
||||
@Inject(HTTP_MODEL_BOSS_CLIENT)
|
||||
private readonly modelClient: HttpModelBossClient,
|
||||
) {}
|
||||
|
||||
async classifyWithModerator(imageBase64: string): Promise<ModeratorResult> {
|
||||
this.logger.logWithData('debug', 'Calling imajin-moderator');
|
||||
return this.modelClient.post<
|
||||
{ image_base64: string; skip_hash_check: boolean },
|
||||
ModeratorResult
|
||||
>('imajin-moderator', '/scan', { image_base64: imageBase64, skip_hash_check: true });
|
||||
}
|
||||
|
||||
async classifyWithSiglip(imageBase64: string, requestedFilters: string[]): Promise<SiglipResult> {
|
||||
this.logger.logWithData('debug', 'Calling imajin-semantic');
|
||||
return this.modelClient.post<
|
||||
{ image_base64: string; requested_filters: string[] },
|
||||
SiglipResult
|
||||
>('imajin-semantic', '/detect', { image_base64: imageBase64, requested_filters: requestedFilters });
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue