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
14 changes: 9 additions & 5 deletions apps/api/src/lib/import/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,17 @@ import {
import {
ANALYSIS_MODEL_ID,
DEFAULT_MODEL_ID,
GeminiImageClassifier,
GeminiProductIntelligenceAgent,
GeminiMaterialInferenceEngine,
GeminiProductClusterAnalyzer,
ImageDeduplicationService,
createGenerativeModel,
} from '@minimalblock/ai';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@minimalblock/data';
import { ScraperAdapterRegistry } from './adapters/adapter-registry.js';
import { ImageUploadPipeline } from './pipeline/image-upload.pipeline.js';
import { ImageIntelligencePipeline } from './pipeline/image-intelligence.pipeline.js';
import { ProductIntelligencePipeline } from './pipeline/product-intelligence.pipeline.js';
import { AutofillPipeline, inferCategory, cleanTitle, cleanText } from './pipeline/autofill.pipeline.js';
import { ClusterDetectionPipeline } from './pipeline/cluster.pipeline.js';
import { MaterialInferencePipeline } from './pipeline/material.pipeline.js';
Expand Down Expand Up @@ -68,7 +69,7 @@ function buildField<T>(
export class ExtractionOrchestrator {
private readonly registry: ScraperAdapterRegistry;
private readonly uploadPipeline: ImageUploadPipeline;
private readonly intelligencePipeline: ImageIntelligencePipeline;
private readonly intelligencePipeline: ProductIntelligencePipeline;
private readonly autofillPipeline: AutofillPipeline;
private readonly clusterPipeline: ClusterDetectionPipeline;
private readonly materialPipeline: MaterialInferencePipeline;
Expand All @@ -79,7 +80,10 @@ export class ExtractionOrchestrator {

this.registry = new ScraperAdapterRegistry();
this.uploadPipeline = new ImageUploadPipeline(options.admin, options.ownerId);
this.intelligencePipeline = new ImageIntelligencePipeline(new GeminiImageClassifier(flashModel));
this.intelligencePipeline = new ProductIntelligencePipeline(
new GeminiProductIntelligenceAgent(flashModel),
new ImageDeduplicationService(),
);
this.autofillPipeline = new AutofillPipeline(analysisModel);
this.clusterPipeline = new ClusterDetectionPipeline(new GeminiProductClusterAnalyzer(flashModel));
this.materialPipeline = new MaterialInferencePipeline(new GeminiMaterialInferenceEngine(flashModel));
Expand All @@ -95,7 +99,7 @@ export class ExtractionOrchestrator {
const uploadedImages = await this.uploadPipeline.upload(scrape.images);

// 3. Image intelligence — classify, deduplicate, score (graceful fallback)
let imageIntelligenceResult: Awaited<ReturnType<ImageIntelligencePipeline['analyze']>> = {
let imageIntelligenceResult: Awaited<ReturnType<ProductIntelligencePipeline['analyze']>> = {
candidates: uploadedImages,
summary: undefined,
};
Expand Down
139 changes: 139 additions & 0 deletions apps/api/src/lib/import/pipeline/product-intelligence.pipeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import type { ImportedImageCandidate, ProductImportData } from '@minimalblock/core';
import { GeminiProductIntelligenceAgent, ImageDeduplicationService } from '@minimalblock/ai';

export interface ImageIntelligenceResult {
candidates: ImportedImageCandidate[];
summary: ProductImportData['imageIntelligence'];
}

export class ProductIntelligencePipeline {
constructor(
private readonly agent: GeminiProductIntelligenceAgent,
private readonly deduplicator: ImageDeduplicationService,
) {}

async analyze(
candidates: ImportedImageCandidate[],
productTitleHint?: string,
): Promise<ImageIntelligenceResult> {
const totalBefore = candidates.length;
if (candidates.length === 0) {
return {
candidates,
summary: {
totalCandidatesBeforeFiltering: 0,
rejectedByAi: 0,
duplicatesRemoved: 0,
variantImagesDetected: 0,
datasetCoherence: 'low',
reconstructionReadiness: 'blocked',
productIdentityScore: 0,
uncertaintyLevel: 'high',
perspectiveDiversity: 'limited',
intelligenceNotes: ['No candidates provided.'],
},
};
}

// Fetch all candidate image buffers
const buffers = await Promise.all(
candidates.map(async (candidate) => {
if (!candidate.url) return null;
try {
const res = await fetch(candidate.url, {
headers: { 'user-agent': 'MinimalBlockBot/1.0', accept: 'image/*' },
});
if (!res.ok) return null;
return new Uint8Array(await res.arrayBuffer());
} catch {
return null;
}
}),
);

// Perceptual deduplication
const hashes = buffers.map((buf) =>
buf ? this.deduplicator.computeHash(buf) : '0000000000000000',
);
const duplicateIndexes = new Set(this.deduplicator.findDuplicates(hashes));

// Build base64 images for agent (only non-failed, non-SVG)
const agentImages: Array<{ base64: string; mimeType: string; originalIndex: number }> = [];
for (let i = 0; i < candidates.length; i++) {
const buf = buffers[i];
if (buf && candidates[i].mimeType && candidates[i].mimeType !== 'image/svg+xml') {
agentImages.push({
base64: btoa(String.fromCharCode(...buf)),
mimeType: candidates[i].mimeType!,
originalIndex: i,
});
}
}

// Single Gemini call — contextual reasoning over the full image set
let agentResult: Awaited<ReturnType<GeminiProductIntelligenceAgent['analyze']>> | undefined;
try {
agentResult = await this.agent.analyze(
agentImages.map((img) => ({ base64: img.base64, mimeType: img.mimeType })),
productTitleHint,
);
} catch {
// Graceful fallback: proceed without APIA
}

let rejectedCount = 0;
let variantCount = 0;

const enriched: ImportedImageCandidate[] = candidates.map((candidate, originalIndex) => {
const agentIdx = agentImages.findIndex((g) => g.originalIndex === originalIndex);
const imageResult = agentIdx >= 0 ? agentResult?.images[agentIdx] : undefined;
const isDuplicate = duplicateIndexes.has(originalIndex);
const isRejected = isDuplicate || (imageResult?.rejected ?? false);

if (isRejected) rejectedCount++;
if (imageResult?.rejectionReason?.startsWith('variant:') || candidate.variantKey) variantCount++;

return {
...candidate,
perceptualHash: hashes[originalIndex] !== '0000000000000000' ? hashes[originalIndex] : undefined,
aiImageClass: imageResult?.imageClass,
aiRelevanceScore: imageResult?.relevanceScore,
aiRejected: isRejected,
aiRejectionReason: isDuplicate ? 'duplicate' : imageResult?.rejectionReason,
viewAngle: imageResult?.viewAngle,
informationValue: imageResult?.informationValue,
geometricContribution: imageResult?.geometricContribution,
};
});

// Sort: high-information non-rejected first, then by relevance, rejected last
const sorted = [...enriched].sort((a, b) => {
if (a.aiRejected && !b.aiRejected) return 1;
if (!a.aiRejected && b.aiRejected) return -1;
const infoOrder = { high: 0, medium: 1, low: 2 };
const aInfo = infoOrder[a.informationValue ?? 'medium'];
const bInfo = infoOrder[b.informationValue ?? 'medium'];
if (aInfo !== bInfo) return aInfo - bInfo;
return (b.aiRelevanceScore ?? 0.5) - (a.aiRelevanceScore ?? 0.5);
});

const dataset = agentResult?.dataset;

return {
candidates: sorted,
summary: {
totalCandidatesBeforeFiltering: totalBefore,
rejectedByAi: rejectedCount,
duplicatesRemoved: duplicateIndexes.size,
variantImagesDetected: variantCount,
datasetCoherence: dataset?.datasetCoherence,
reconstructionReadiness: dataset?.reconstructionReadiness,
reconstructionBlockReason: dataset?.reconstructionBlockReason,
productIdentityScore: dataset?.productIdentityScore,
uncertaintyLevel: dataset?.uncertaintyLevel,
perspectiveDiversity: dataset?.perspectiveDiversity,
intelligenceNotes: dataset?.intelligenceNotes,
},
};
}
}
2 changes: 1 addition & 1 deletion apps/web/src/pages/GalleryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export function GalleryPage({ user }: GalleryPageProps) {
productRepo.findByOwnerId(user.id).then((list) => {
setProducts(new Map(list.map((product) => [product.id, product])));
});
}, [productRepo, user.id, conversions]);
}, [productRepo, user.id]);

const galleryModels = useMemo(
() => {
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/pages/ProductDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,17 +216,19 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) {

useEffect(() => {
if (!conversion || conversion.status.isTerminal()) return;
const conversionId = conversion.id;
const interval = window.setInterval(async () => {
try {
const response = await apiClient.getConversion(conversion.id);
const response = await apiClient.getConversion(conversionId);
setConversion(hydrateConversion(response.conversion));
} catch {
window.clearInterval(interval);
}
}, 2500);

return () => window.clearInterval(interval);
}, [apiClient, conversion]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [apiClient, conversion?.id, conversion?.status.value]);

const productName = product?.name ?? conversion?.sourceAsset.storageKey.split('/').pop() ?? 'Product';
const visibleHotspots = useMemo(() => hotspots.filter((hotspot) => hotspot.position && hotspot.normal), [hotspots]);
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/pages/UploadPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,18 @@ export function UploadPage({ user }: UploadPageProps) {

useEffect(() => {
if (!conversion || !isPolling) return;
const conversionId = conversion.id;
const interval = window.setInterval(async () => {
try {
const response = await apiClient.getConversion(conversion.id);
const response = await apiClient.getConversion(conversionId);
setConversion(response.conversion);
} catch (error) {
setSubmitError(error instanceof Error ? error.message : 'Failed to refresh.');
window.clearInterval(interval);
}
}, 2500);
return () => window.clearInterval(interval);
}, [apiClient, conversion, isPolling]);
}, [apiClient, conversion?.id, isPolling]);

const sortedSourceAssets = useMemo(
() => [...sourceAssets].sort((a, b) => a.storageKey.localeCompare(b.storageKey)),
Expand Down
6 changes: 6 additions & 0 deletions libs/ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export type { VisualQaInput } from './lib/gemini/gemini-visual-qa.js';
// APUS — AI service classes
export { GeminiImageClassifier } from './lib/gemini/gemini-image-classifier.js';
export type { ImageClassificationResult } from './lib/gemini/gemini-image-classifier.js';
// APIA — Product Intelligence Agent
export { GeminiProductIntelligenceAgent } from './lib/gemini/gemini-product-intelligence-agent.js';
export type { ProductIntelligenceOutput, PerImageIntelligence, DatasetIntelligence } from './lib/gemini/gemini-product-intelligence-agent.js';
export { GeminiProductClusterAnalyzer } from './lib/gemini/gemini-product-cluster-analyzer.js';
export type { MultiProductDetectionResult } from './lib/gemini/gemini-product-cluster-analyzer.js';
export { GeminiMaterialInferenceEngine } from './lib/gemini/gemini-material-inference.js';
Expand All @@ -27,6 +30,9 @@ export type { TrendyolListingInput } from './lib/prompts/trendyol-listing.js';
// APUS — prompt builders
export { buildImageClassificationPrompt } from './lib/prompts/image-classification.prompt.js';
export type { ImageClassificationPromptInput } from './lib/prompts/image-classification.prompt.js';
// APIA — product intelligence prompt
export { buildProductIntelligencePrompt } from './lib/prompts/product-intelligence.prompt.js';
export type { ProductIntelligencePromptInput } from './lib/prompts/product-intelligence.prompt.js';
export { buildMultiProductDetectionPrompt } from './lib/prompts/multi-product-detection.prompt.js';
export type { MultiProductDetectionInput, DetectedCluster } from './lib/prompts/multi-product-detection.prompt.js';
export { buildMaterialInferencePrompt } from './lib/prompts/material-inference.prompt.js';
Expand Down
62 changes: 62 additions & 0 deletions libs/ai/src/lib/gemini/gemini-product-intelligence-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { GenerativeModel } from '@google/generative-ai';
import {
buildProductIntelligencePrompt,
type ProductIntelligenceOutput,
type PerImageIntelligence,
type DatasetIntelligence,
} from '../prompts/product-intelligence.prompt.js';

export type { ProductIntelligenceOutput, PerImageIntelligence, DatasetIntelligence };

const SAFE_IMAGE_DEFAULTS: Omit<PerImageIntelligence, 'index'> = {
imageClass: 'unknown',
relevanceScore: 0.5,
viewAngle: 'unknown',
rejected: false,
informationValue: 'medium',
geometricContribution: 'secondary',
};

const SAFE_DATASET_DEFAULTS: DatasetIntelligence = {
productIdentityScore: 0.5,
datasetCoherence: 'medium',
reconstructionReadiness: 'degraded',
uncertaintyLevel: 'high',
perspectiveDiversity: 'limited',
intelligenceNotes: ['Dataset intelligence unavailable — safe degradation applied.'],
};

export class GeminiProductIntelligenceAgent {
constructor(private readonly model: GenerativeModel) {}

async analyze(
images: Array<{ base64: string; mimeType: string }>,
productTitleHint?: string,
): Promise<ProductIntelligenceOutput> {
if (images.length === 0) {
return {
images: [],
dataset: { ...SAFE_DATASET_DEFAULTS, intelligenceNotes: ['No images provided.'] },
};
}

const prompt = buildProductIntelligencePrompt({ imageCount: images.length, productTitleHint });
const parts: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> = [
{ text: prompt },
...images.map((img) => ({ inlineData: { mimeType: img.mimeType, data: img.base64 } })),
];

const result = await this.model.generateContent(parts);
const raw = result.response.text().trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
const parsed = JSON.parse(raw) as ProductIntelligenceOutput;

const enrichedImages = images.map((_, index) => {
const found = parsed.images?.find((item) => item.index === index);
return found ? { ...found, index } : { ...SAFE_IMAGE_DEFAULTS, index };
});

const dataset: DatasetIntelligence = parsed.dataset ?? SAFE_DATASET_DEFAULTS;

return { images: enrichedImages, dataset };
}
}
Loading
Loading