feat(transfer): add Claude Vision condition assessment for transfer pricing
Add POST /transfer/estimate-from-photos endpoint that uses Claude Vision API to assess furniture/appliance condition from photos, integrating with the existing rule-based pricing engine. Includes rate limiting (5/min), image hash caching, graceful fallback, and 17 unit tests covering all paths. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
import type { IClaudeVisionService, VisionAssessmentResult } from '../../infrastructure/services/claude-vision.service';
|
||||
import { EstimateFromPhotosCommand } from '../commands/estimate-from-photos/estimate-from-photos.command';
|
||||
import { EstimateFromPhotosHandler } from '../commands/estimate-from-photos/estimate-from-photos.handler';
|
||||
|
||||
function mockVisionResult(overrides?: Partial<VisionAssessmentResult>): VisionAssessmentResult {
|
||||
return {
|
||||
condition: 'GOOD',
|
||||
conditionConfidence: 0.85,
|
||||
detectedBrand: null,
|
||||
detectedCategory: null,
|
||||
qualityScore: 70,
|
||||
description: 'Ghế sofa tình trạng tốt, ít trầy xước.',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('EstimateFromPhotosHandler', () => {
|
||||
let handler: EstimateFromPhotosHandler;
|
||||
let mockVisionService: { [K in keyof IClaudeVisionService]: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockVisionService = {
|
||||
assessFurnitureCondition: vi.fn(),
|
||||
isAvailable: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
|
||||
handler = new EstimateFromPhotosHandler(
|
||||
mockVisionService as any,
|
||||
{ error: vi.fn() } as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns vision assessment with pricing estimates', async () => {
|
||||
mockVisionService.assessFurnitureCondition.mockResolvedValue(
|
||||
mockVisionResult({ condition: 'LIKE_NEW', detectedBrand: 'IKEA' }),
|
||||
);
|
||||
|
||||
const command = new EstimateFromPhotosCommand([
|
||||
{
|
||||
imageUrls: ['https://example.com/sofa.jpg'],
|
||||
category: 'FURNITURE',
|
||||
originalPriceVND: 15_000_000,
|
||||
purchaseYear: 2024,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.visionAvailable).toBe(true);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].visionAssessment.condition).toBe('LIKE_NEW');
|
||||
expect(result.items[0].visionAssessment.detectedBrand).toBe('IKEA');
|
||||
expect(result.items[0].visionAdjustedEstimate).not.toBeNull();
|
||||
expect(result.items[0].ruleBasedEstimate).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null pricing when category/price/year not provided', async () => {
|
||||
mockVisionService.assessFurnitureCondition.mockResolvedValue(mockVisionResult());
|
||||
|
||||
const command = new EstimateFromPhotosCommand([
|
||||
{ imageUrls: ['https://example.com/chair.jpg'] },
|
||||
]);
|
||||
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.items[0].visionAssessment).toBeDefined();
|
||||
expect(result.items[0].ruleBasedEstimate).toBeNull();
|
||||
expect(result.items[0].visionAdjustedEstimate).toBeNull();
|
||||
});
|
||||
|
||||
it('uses Vision-detected category when user did not provide one', async () => {
|
||||
mockVisionService.assessFurnitureCondition.mockResolvedValue(
|
||||
mockVisionResult({ detectedCategory: 'APPLIANCE', condition: 'FAIR' }),
|
||||
);
|
||||
|
||||
const command = new EstimateFromPhotosCommand([
|
||||
{
|
||||
imageUrls: ['https://example.com/washer.jpg'],
|
||||
originalPriceVND: 10_000_000,
|
||||
purchaseYear: 2022,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await handler.execute(command);
|
||||
|
||||
// visionAdjustedEstimate should use APPLIANCE category
|
||||
expect(result.items[0].visionAdjustedEstimate).not.toBeNull();
|
||||
// ruleBasedEstimate should be null since no user-provided category
|
||||
expect(result.items[0].ruleBasedEstimate).toBeNull();
|
||||
});
|
||||
|
||||
it('handles multiple items in batch', async () => {
|
||||
mockVisionService.assessFurnitureCondition
|
||||
.mockResolvedValueOnce(mockVisionResult({ condition: 'LIKE_NEW' }))
|
||||
.mockResolvedValueOnce(mockVisionResult({ condition: 'WORN' }));
|
||||
|
||||
const command = new EstimateFromPhotosCommand([
|
||||
{
|
||||
imageUrls: ['https://example.com/item1.jpg'],
|
||||
category: 'FURNITURE',
|
||||
originalPriceVND: 10_000_000,
|
||||
purchaseYear: 2023,
|
||||
},
|
||||
{
|
||||
imageUrls: ['https://example.com/item2.jpg'],
|
||||
category: 'KITCHEN',
|
||||
originalPriceVND: 5_000_000,
|
||||
purchaseYear: 2020,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.items[0].visionAssessment.condition).toBe('LIKE_NEW');
|
||||
expect(result.items[1].visionAssessment.condition).toBe('WORN');
|
||||
expect(mockVisionService.assessFurnitureCondition).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reports visionAvailable=false when service unavailable', async () => {
|
||||
mockVisionService.isAvailable.mockReturnValue(false);
|
||||
mockVisionService.assessFurnitureCondition.mockResolvedValue(
|
||||
mockVisionResult({ conditionConfidence: 0.3 }),
|
||||
);
|
||||
|
||||
const command = new EstimateFromPhotosCommand([
|
||||
{ imageUrls: ['https://example.com/img.jpg'] },
|
||||
]);
|
||||
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.visionAvailable).toBe(false);
|
||||
// Still returns results (fallback)
|
||||
expect(result.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('uses Vision-detected brand for pricing adjustment', async () => {
|
||||
mockVisionService.assessFurnitureCondition.mockResolvedValue(
|
||||
mockVisionResult({ detectedBrand: 'Herman Miller', condition: 'GOOD' }),
|
||||
);
|
||||
|
||||
const command = new EstimateFromPhotosCommand([
|
||||
{
|
||||
imageUrls: ['https://example.com/chair.jpg'],
|
||||
category: 'OFFICE_EQUIPMENT',
|
||||
originalPriceVND: 30_000_000,
|
||||
purchaseYear: 2023,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await handler.execute(command);
|
||||
|
||||
// Vision-adjusted should use Herman Miller brand multiplier (1.3x)
|
||||
const adjusted = result.items[0].visionAdjustedEstimate;
|
||||
const ruleBased = result.items[0].ruleBasedEstimate;
|
||||
expect(adjusted).not.toBeNull();
|
||||
expect(ruleBased).not.toBeNull();
|
||||
// Herman Miller (premium) should yield higher estimate than no-brand rule-based
|
||||
expect(Number(adjusted!.estimatedPriceVND)).toBeGreaterThan(Number(ruleBased!.estimatedPriceVND));
|
||||
});
|
||||
|
||||
it('passes image data correctly to vision service', async () => {
|
||||
mockVisionService.assessFurnitureCondition.mockResolvedValue(mockVisionResult());
|
||||
|
||||
const command = new EstimateFromPhotosCommand([
|
||||
{
|
||||
imageUrls: ['https://example.com/a.jpg', 'https://example.com/b.jpg'],
|
||||
imageBase64: [{ data: 'base64data', mediaType: 'image/jpeg' }],
|
||||
},
|
||||
]);
|
||||
|
||||
await handler.execute(command);
|
||||
|
||||
expect(mockVisionService.assessFurnitureCondition).toHaveBeenCalledWith({
|
||||
imageUrls: ['https://example.com/a.jpg', 'https://example.com/b.jpg'],
|
||||
imageBase64: [{ data: 'base64data', mediaType: 'image/jpeg' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { TransferCategory } from '@prisma/client';
|
||||
|
||||
export interface PhotoEstimateItemInput {
|
||||
imageUrls?: string[];
|
||||
imageBase64?: { data: string; mediaType: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' }[];
|
||||
category?: TransferCategory;
|
||||
originalPriceVND?: number;
|
||||
purchaseYear?: number;
|
||||
brand?: string;
|
||||
}
|
||||
|
||||
export class EstimateFromPhotosCommand {
|
||||
constructor(
|
||||
public readonly items: PhotoEstimateItemInput[],
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import type { TransferCategory, TransferCondition } from '@prisma/client';
|
||||
import { DomainException, LoggerService } from '@modules/shared';
|
||||
import {
|
||||
estimateFurniturePrice,
|
||||
type FurniturePriceEstimate,
|
||||
} from '../../../domain/services/furniture-pricing.service';
|
||||
import {
|
||||
CLAUDE_VISION_SERVICE,
|
||||
type IClaudeVisionService,
|
||||
type VisionAssessmentResult,
|
||||
} from '../../../infrastructure/services/claude-vision.service';
|
||||
import { EstimateFromPhotosCommand, type PhotoEstimateItemInput } from './estimate-from-photos.command';
|
||||
|
||||
export interface PhotoEstimateItemResult {
|
||||
visionAssessment: VisionAssessmentResult;
|
||||
ruleBasedEstimate: {
|
||||
estimatedPriceVND: string;
|
||||
confidence: number;
|
||||
factors: FurniturePriceEstimate['factors'];
|
||||
} | null;
|
||||
visionAdjustedEstimate: {
|
||||
estimatedPriceVND: string;
|
||||
confidence: number;
|
||||
factors: FurniturePriceEstimate['factors'];
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface PhotoEstimateResult {
|
||||
items: PhotoEstimateItemResult[];
|
||||
visionAvailable: boolean;
|
||||
}
|
||||
|
||||
@CommandHandler(EstimateFromPhotosCommand)
|
||||
export class EstimateFromPhotosHandler implements ICommandHandler<EstimateFromPhotosCommand> {
|
||||
constructor(
|
||||
@Inject(CLAUDE_VISION_SERVICE) private readonly visionService: IClaudeVisionService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(command: EstimateFromPhotosCommand): Promise<PhotoEstimateResult> {
|
||||
try {
|
||||
const visionAvailable = this.visionService.isAvailable();
|
||||
const results: PhotoEstimateItemResult[] = [];
|
||||
|
||||
for (const item of command.items) {
|
||||
const result = await this.assessItem(item);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
return { items: results, visionAvailable };
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to estimate from photos: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể ước tính giá từ ảnh');
|
||||
}
|
||||
}
|
||||
|
||||
private async assessItem(item: PhotoEstimateItemInput): Promise<PhotoEstimateItemResult> {
|
||||
// Get Vision assessment
|
||||
const visionAssessment = await this.visionService.assessFurnitureCondition({
|
||||
imageUrls: item.imageUrls,
|
||||
imageBase64: item.imageBase64,
|
||||
});
|
||||
|
||||
// Rule-based estimate using user-provided condition (if enough data)
|
||||
const ruleBasedEstimate = this.tryRuleBasedEstimate(
|
||||
item.category,
|
||||
undefined, // no condition override — use default GOOD
|
||||
item.originalPriceVND,
|
||||
item.purchaseYear,
|
||||
item.brand,
|
||||
);
|
||||
|
||||
// Vision-adjusted estimate using Vision-assessed condition
|
||||
const category = visionAssessment.detectedCategory ?? item.category;
|
||||
const brand = visionAssessment.detectedBrand ?? item.brand;
|
||||
const visionAdjustedEstimate = this.tryRuleBasedEstimate(
|
||||
category,
|
||||
visionAssessment.condition,
|
||||
item.originalPriceVND,
|
||||
item.purchaseYear,
|
||||
brand,
|
||||
);
|
||||
|
||||
return { visionAssessment, ruleBasedEstimate, visionAdjustedEstimate };
|
||||
}
|
||||
|
||||
private tryRuleBasedEstimate(
|
||||
category: TransferCategory | undefined,
|
||||
condition: TransferCondition | undefined,
|
||||
originalPriceVND: number | undefined,
|
||||
purchaseYear: number | undefined,
|
||||
brand: string | undefined,
|
||||
): PhotoEstimateItemResult['ruleBasedEstimate'] {
|
||||
if (!category || !originalPriceVND || !purchaseYear) return null;
|
||||
|
||||
const estimate = estimateFurniturePrice({
|
||||
category,
|
||||
condition: condition ?? 'GOOD',
|
||||
originalPriceVND: BigInt(Math.round(originalPriceVND)),
|
||||
purchaseYear,
|
||||
brand,
|
||||
});
|
||||
|
||||
return {
|
||||
estimatedPriceVND: estimate.estimatedPriceVND.toString(),
|
||||
confidence: estimate.confidence,
|
||||
factors: estimate.factors,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { EstimateFromPhotosCommand } from './estimate-from-photos.command';
|
||||
export { EstimateFromPhotosHandler } from './estimate-from-photos.handler';
|
||||
@@ -0,0 +1,190 @@
|
||||
import { ClaudeVisionService } from '../services/claude-vision.service';
|
||||
|
||||
// We test the service with mocked Anthropic client
|
||||
// The constructor reads CLAUDE_API_KEY from ConfigService
|
||||
|
||||
describe('ClaudeVisionService', () => {
|
||||
let service: ClaudeVisionService;
|
||||
let mockConfig: { get: ReturnType<typeof vi.fn> };
|
||||
let mockCache: {
|
||||
getOrSet: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
describe('without API key', () => {
|
||||
beforeEach(() => {
|
||||
mockConfig = { get: vi.fn().mockReturnValue(undefined) };
|
||||
mockCache = { getOrSet: vi.fn().mockImplementation(async (_k, loader) => loader()) };
|
||||
service = new ClaudeVisionService(mockConfig as any, mockCache as any);
|
||||
});
|
||||
|
||||
it('reports unavailable when no API key', () => {
|
||||
expect(service.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns fallback assessment when no API key', async () => {
|
||||
const result = await service.assessFurnitureCondition({
|
||||
imageUrls: ['https://example.com/img.jpg'],
|
||||
});
|
||||
|
||||
expect(result.condition).toBe('GOOD');
|
||||
expect(result.conditionConfidence).toBe(0.3);
|
||||
expect(result.description).toContain('không khả dụng');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with API key (mocked client)', () => {
|
||||
let mockCreate: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = { get: vi.fn().mockReturnValue('test-api-key') };
|
||||
mockCache = { getOrSet: vi.fn().mockImplementation(async (_k, loader) => loader()) };
|
||||
service = new ClaudeVisionService(mockConfig as any, mockCache as any);
|
||||
|
||||
// Replace the private client with a mock
|
||||
mockCreate = vi.fn();
|
||||
(service as any).client = { messages: { create: mockCreate } };
|
||||
});
|
||||
|
||||
it('reports available when API key present', () => {
|
||||
expect(service.isAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it('parses valid Vision response', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
condition: 'like-new',
|
||||
conditionConfidence: 0.9,
|
||||
brand: 'IKEA',
|
||||
category: 'furniture',
|
||||
qualityScore: 85,
|
||||
description: 'Ghế sofa IKEA tình trạng rất tốt',
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await service.assessFurnitureCondition({
|
||||
imageUrls: ['https://example.com/sofa.jpg'],
|
||||
});
|
||||
|
||||
expect(result.condition).toBe('LIKE_NEW');
|
||||
expect(result.conditionConfidence).toBe(0.9);
|
||||
expect(result.detectedBrand).toBe('IKEA');
|
||||
expect(result.detectedCategory).toBe('FURNITURE');
|
||||
expect(result.qualityScore).toBe(85);
|
||||
});
|
||||
|
||||
it('maps "poor" condition to WORN', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
condition: 'poor',
|
||||
conditionConfidence: 0.7,
|
||||
brand: 'unknown',
|
||||
category: 'appliance',
|
||||
qualityScore: 20,
|
||||
description: 'Máy giặt cũ, nhiều vết rỉ sét',
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await service.assessFurnitureCondition({
|
||||
imageUrls: ['https://example.com/washer.jpg'],
|
||||
});
|
||||
|
||||
expect(result.condition).toBe('WORN');
|
||||
expect(result.detectedBrand).toBeNull(); // 'unknown' maps to null
|
||||
expect(result.detectedCategory).toBe('APPLIANCE');
|
||||
});
|
||||
|
||||
it('returns fallback on API error', async () => {
|
||||
mockCreate.mockRejectedValue(new Error('API timeout'));
|
||||
|
||||
const result = await service.assessFurnitureCondition({
|
||||
imageUrls: ['https://example.com/img.jpg'],
|
||||
});
|
||||
|
||||
expect(result.condition).toBe('GOOD');
|
||||
expect(result.conditionConfidence).toBe(0.3);
|
||||
});
|
||||
|
||||
it('returns fallback on invalid JSON response', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
content: [{ type: 'text', text: 'This is not valid JSON at all' }],
|
||||
});
|
||||
|
||||
const result = await service.assessFurnitureCondition({
|
||||
imageUrls: ['https://example.com/img.jpg'],
|
||||
});
|
||||
|
||||
expect(result.condition).toBe('GOOD');
|
||||
expect(result.conditionConfidence).toBe(0.3);
|
||||
});
|
||||
|
||||
it('returns fallback when no images provided', async () => {
|
||||
const result = await service.assessFurnitureCondition({});
|
||||
|
||||
expect(result.condition).toBe('GOOD');
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clamps confidence and quality score to valid ranges', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
condition: 'good',
|
||||
conditionConfidence: 1.5, // over 1
|
||||
brand: 'unknown',
|
||||
category: 'furniture',
|
||||
qualityScore: 150, // over 100
|
||||
description: 'Test',
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await service.assessFurnitureCondition({
|
||||
imageUrls: ['https://example.com/img.jpg'],
|
||||
});
|
||||
|
||||
expect(result.conditionConfidence).toBe(1);
|
||||
expect(result.qualityScore).toBe(100);
|
||||
});
|
||||
|
||||
it('sends image content to Claude API correctly', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
content: [{ type: 'text', text: '{"condition":"good","conditionConfidence":0.8,"brand":"unknown","category":"furniture","qualityScore":70,"description":"OK"}' }],
|
||||
});
|
||||
|
||||
await service.assessFurnitureCondition({
|
||||
imageUrls: ['https://example.com/a.jpg'],
|
||||
imageBase64: [{ data: 'abc123', mediaType: 'image/jpeg' }],
|
||||
});
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: expect.any(String),
|
||||
max_tokens: 1024,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'image', source: expect.objectContaining({ type: 'url' }) }),
|
||||
expect.objectContaining({ type: 'image', source: expect.objectContaining({ type: 'base64' }) }),
|
||||
expect.objectContaining({ type: 'text' }),
|
||||
]),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import { createHash } from 'crypto';
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { type ConfigService } from '@nestjs/config';
|
||||
import type { TransferCategory, TransferCondition } from '@prisma/client';
|
||||
import { CachePrefix, type CacheService } from '@modules/shared';
|
||||
|
||||
export interface VisionAssessmentInput {
|
||||
imageUrls?: string[];
|
||||
imageBase64?: { data: string; mediaType: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' }[];
|
||||
}
|
||||
|
||||
export interface VisionAssessmentResult {
|
||||
condition: TransferCondition;
|
||||
conditionConfidence: number;
|
||||
detectedBrand: string | null;
|
||||
detectedCategory: TransferCategory | null;
|
||||
qualityScore: number; // 0-100
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const CLAUDE_VISION_SERVICE = Symbol('CLAUDE_VISION_SERVICE');
|
||||
|
||||
export interface IClaudeVisionService {
|
||||
assessFurnitureCondition(input: VisionAssessmentInput): Promise<VisionAssessmentResult>;
|
||||
isAvailable(): boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ClaudeVisionService implements IClaudeVisionService {
|
||||
private readonly logger = new Logger(ClaudeVisionService.name);
|
||||
private readonly client: Anthropic | null;
|
||||
private readonly model = 'claude-sonnet-4-20250514';
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly cache: CacheService,
|
||||
) {
|
||||
const apiKey = this.config.get<string>('CLAUDE_API_KEY');
|
||||
this.client = apiKey ? new Anthropic({ apiKey }) : null;
|
||||
|
||||
if (!this.client) {
|
||||
this.logger.warn('CLAUDE_API_KEY not configured — Vision assessment will use fallback.');
|
||||
}
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return this.client !== null;
|
||||
}
|
||||
|
||||
async assessFurnitureCondition(input: VisionAssessmentInput): Promise<VisionAssessmentResult> {
|
||||
const cacheKey = this.buildCacheKey(input);
|
||||
const cached = await this.tryGetCached(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
if (!this.client) {
|
||||
return this.fallbackAssessment();
|
||||
}
|
||||
|
||||
try {
|
||||
const imageContent = this.buildImageContent(input);
|
||||
if (imageContent.length === 0) {
|
||||
return this.fallbackAssessment();
|
||||
}
|
||||
|
||||
const response = await this.client.messages.create({
|
||||
model: this.model,
|
||||
max_tokens: 1024,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
...imageContent,
|
||||
{ type: 'text', text: USER_PROMPT },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const text = response.content
|
||||
.filter((block): block is Anthropic.TextBlock => block.type === 'text')
|
||||
.map((block) => block.text)
|
||||
.join('');
|
||||
|
||||
const result = this.parseAssessment(text);
|
||||
await this.cacheResult(cacheKey, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Claude Vision API error: ${error instanceof Error ? error.message : 'Unknown'}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
return this.fallbackAssessment();
|
||||
}
|
||||
}
|
||||
|
||||
private buildImageContent(
|
||||
input: VisionAssessmentInput,
|
||||
): Anthropic.Messages.ImageBlockParam[] {
|
||||
const blocks: Anthropic.Messages.ImageBlockParam[] = [];
|
||||
|
||||
if (input.imageUrls) {
|
||||
for (const url of input.imageUrls.slice(0, 4)) {
|
||||
blocks.push({
|
||||
type: 'image',
|
||||
source: { type: 'url', url },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (input.imageBase64) {
|
||||
for (const img of input.imageBase64.slice(0, 4)) {
|
||||
blocks.push({
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: img.mediaType,
|
||||
data: img.data,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return blocks.slice(0, 4); // Claude max 4 images per request
|
||||
}
|
||||
|
||||
private parseAssessment(text: string): VisionAssessmentResult {
|
||||
try {
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) return this.fallbackAssessment();
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0]) as Record<string, unknown>;
|
||||
|
||||
const condition = this.mapCondition(parsed['condition'] as string);
|
||||
const conditionConfidence = Math.max(0, Math.min(1, Number(parsed['conditionConfidence']) || 0.5));
|
||||
const detectedBrand = typeof parsed['brand'] === 'string' && parsed['brand'] !== 'unknown'
|
||||
? parsed['brand']
|
||||
: null;
|
||||
const detectedCategory = this.mapCategory(parsed['category'] as string);
|
||||
const qualityScore = Math.max(0, Math.min(100, Math.round(Number(parsed['qualityScore']) || 50)));
|
||||
const description = typeof parsed['description'] === 'string'
|
||||
? parsed['description']
|
||||
: 'Không có mô tả';
|
||||
|
||||
return { condition, conditionConfidence, detectedBrand, detectedCategory, qualityScore, description };
|
||||
} catch {
|
||||
this.logger.warn('Failed to parse Vision response, using fallback');
|
||||
return this.fallbackAssessment();
|
||||
}
|
||||
}
|
||||
|
||||
private mapCondition(raw: string | undefined): TransferCondition {
|
||||
const map: Record<string, TransferCondition> = {
|
||||
new: 'NEW',
|
||||
'like-new': 'LIKE_NEW',
|
||||
like_new: 'LIKE_NEW',
|
||||
good: 'GOOD',
|
||||
fair: 'FAIR',
|
||||
poor: 'WORN',
|
||||
worn: 'WORN',
|
||||
};
|
||||
return map[raw?.toLowerCase() ?? ''] ?? 'GOOD';
|
||||
}
|
||||
|
||||
private mapCategory(raw: string | undefined): TransferCategory | null {
|
||||
const map: Record<string, TransferCategory> = {
|
||||
furniture: 'FURNITURE',
|
||||
appliance: 'APPLIANCE',
|
||||
office_equipment: 'OFFICE_EQUIPMENT',
|
||||
kitchen: 'KITCHEN',
|
||||
premises: 'PREMISES',
|
||||
full_unit: 'FULL_UNIT',
|
||||
};
|
||||
return map[raw?.toLowerCase() ?? ''] ?? null;
|
||||
}
|
||||
|
||||
private fallbackAssessment(): VisionAssessmentResult {
|
||||
return {
|
||||
condition: 'GOOD',
|
||||
conditionConfidence: 0.3,
|
||||
detectedBrand: null,
|
||||
detectedCategory: null,
|
||||
qualityScore: 50,
|
||||
description: 'Đánh giá AI không khả dụng — sử dụng giá trị mặc định.',
|
||||
};
|
||||
}
|
||||
|
||||
private buildCacheKey(input: VisionAssessmentInput): string {
|
||||
const hash = createHash('sha256');
|
||||
if (input.imageUrls) hash.update(input.imageUrls.sort().join('|'));
|
||||
if (input.imageBase64) hash.update(input.imageBase64.map((i) => i.data.slice(0, 64)).join('|'));
|
||||
return `${CachePrefix.VALUATION}:vision:${hash.digest('hex').slice(0, 16)}`;
|
||||
}
|
||||
|
||||
private async tryGetCached(key: string): Promise<VisionAssessmentResult | null> {
|
||||
try {
|
||||
return await this.cache.getOrSet<VisionAssessmentResult | null>(
|
||||
key,
|
||||
() => Promise.resolve(null),
|
||||
0, // TTL 0 = just try to GET, don't set
|
||||
'vision_assessment',
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async cacheResult(key: string, result: VisionAssessmentResult): Promise<void> {
|
||||
try {
|
||||
await this.cache.getOrSet(
|
||||
key,
|
||||
() => Promise.resolve(result),
|
||||
3600, // Cache for 1 hour
|
||||
'vision_assessment',
|
||||
);
|
||||
} catch {
|
||||
// Cache failure is non-critical
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `You are a furniture and home appliance condition assessor for a Vietnamese real estate transfer marketplace.
|
||||
Analyze the provided images and assess the item's condition for pricing purposes.
|
||||
Respond ONLY with a JSON object — no markdown, no explanation outside the JSON.`;
|
||||
|
||||
const USER_PROMPT = `Assess the furniture/appliance in these images. Return a JSON object with EXACTLY these fields:
|
||||
{
|
||||
"condition": "new" | "like-new" | "good" | "fair" | "poor",
|
||||
"conditionConfidence": 0.0-1.0,
|
||||
"brand": "detected brand name or unknown",
|
||||
"category": "furniture" | "appliance" | "office_equipment" | "kitchen" | "premises" | "full_unit",
|
||||
"qualityScore": 0-100,
|
||||
"description": "Brief Vietnamese description of the item's condition and notable features"
|
||||
}`;
|
||||
@@ -2,14 +2,16 @@ import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@ne
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard, CurrentUser } from '@modules/auth';
|
||||
import { NotFoundException } from '@modules/shared';
|
||||
import { EndpointRateLimit, EndpointRateLimitGuard, NotFoundException } from '@modules/shared';
|
||||
import { CreateTransferListingCommand } from '../../application/commands/create-transfer-listing/create-transfer-listing.command';
|
||||
import { EstimateFromPhotosCommand } from '../../application/commands/estimate-from-photos/estimate-from-photos.command';
|
||||
import { EstimateTransferPricesCommand } from '../../application/commands/estimate-transfer-prices/estimate-transfer-prices.command';
|
||||
import { UpdateTransferListingCommand } from '../../application/commands/update-transfer-listing/update-transfer-listing.command';
|
||||
import { GetTransferListingQuery } from '../../application/queries/get-transfer-listing/get-transfer-listing.query';
|
||||
import { ListTransferListingsQuery } from '../../application/queries/list-transfer-listings/list-transfer-listings.query';
|
||||
import { TransferStatsQuery } from '../../application/queries/transfer-stats/transfer-stats.query';
|
||||
import { type CreateTransferListingDto } from '../dto/create-transfer-listing.dto';
|
||||
import { type EstimateFromPhotosDto } from '../dto/estimate-from-photos.dto';
|
||||
import { type EstimateTransferPricesDto } from '../dto/estimate-transfer-prices.dto';
|
||||
import { type SearchTransferListingsDto } from '../dto/search-transfer-listings.dto';
|
||||
import { type UpdateTransferListingDto } from '../dto/update-transfer-listing.dto';
|
||||
@@ -72,6 +74,21 @@ export class TransferController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: 'Ước tính giá từ ảnh (AI Vision)',
|
||||
description: 'Sử dụng Claude Vision để đánh giá tình trạng nội thất từ ảnh, kết hợp với công cụ định giá rule-based',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Kết quả đánh giá tình trạng và ước tính giá' })
|
||||
@ApiResponse({ status: 429, description: 'Rate limit exceeded' })
|
||||
@EndpointRateLimit({ limit: 5, windowSeconds: 60, keyStrategy: 'ip' })
|
||||
@UseGuards(EndpointRateLimitGuard)
|
||||
@Post('estimate-from-photos')
|
||||
async estimateFromPhotos(@Body() dto: EstimateFromPhotosDto) {
|
||||
return this.commandBus.execute(
|
||||
new EstimateFromPhotosCommand(dto.items),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Authenticated endpoints ───────────────────────────────────────
|
||||
|
||||
@ApiOperation({ summary: 'Tạo tin sang nhượng', description: 'Đăng tin sang nhượng mới' })
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { TransferCategory } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ImageBase64Dto {
|
||||
@ApiProperty({ description: 'Base64-encoded image data' })
|
||||
@IsString()
|
||||
data!: string;
|
||||
|
||||
@ApiProperty({ enum: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] })
|
||||
@IsString()
|
||||
mediaType!: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif';
|
||||
}
|
||||
|
||||
export class PhotoEstimateItemDto {
|
||||
@ApiPropertyOptional({ description: 'Image URLs to assess', type: [String] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUrl({}, { each: true, message: 'URL ảnh không hợp lệ' })
|
||||
@ArrayMaxSize(4, { message: 'Tối đa 4 ảnh mỗi item' })
|
||||
imageUrls?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Base64-encoded images', type: [ImageBase64Dto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ImageBase64Dto)
|
||||
@ArrayMaxSize(4, { message: 'Tối đa 4 ảnh mỗi item' })
|
||||
imageBase64?: ImageBase64Dto[];
|
||||
|
||||
@ApiPropertyOptional({ enum: TransferCategory, description: 'Danh mục (nếu biết)' })
|
||||
@IsOptional()
|
||||
@IsEnum(TransferCategory)
|
||||
category?: TransferCategory;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Giá mua ban đầu (VND)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
originalPriceVND?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Năm mua' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
purchaseYear?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Thương hiệu (nếu biết)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
brand?: string;
|
||||
}
|
||||
|
||||
export class EstimateFromPhotosDto {
|
||||
@ApiProperty({ type: [PhotoEstimateItemDto], description: 'Items to assess from photos' })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PhotoEstimateItemDto)
|
||||
@ArrayMaxSize(5, { message: 'Tối đa 5 items mỗi lần đánh giá' })
|
||||
items!: PhotoEstimateItemDto[];
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { SearchModule } from '@modules/search';
|
||||
import { CreateTransferListingHandler } from './application/commands/create-transfer-listing/create-transfer-listing.handler';
|
||||
import { EstimateFromPhotosHandler } from './application/commands/estimate-from-photos/estimate-from-photos.handler';
|
||||
import { EstimateTransferPricesHandler } from './application/commands/estimate-transfer-prices/estimate-transfer-prices.handler';
|
||||
import { UpdateTransferListingHandler } from './application/commands/update-transfer-listing/update-transfer-listing.handler';
|
||||
import { GetTransferListingHandler } from './application/queries/get-transfer-listing/get-transfer-listing.handler';
|
||||
@@ -9,11 +10,13 @@ import { ListTransferListingsHandler } from './application/queries/list-transfer
|
||||
import { TransferStatsHandler } from './application/queries/transfer-stats/transfer-stats.handler';
|
||||
import { TRANSFER_LISTING_REPOSITORY } from './domain/repositories/transfer-listing.repository';
|
||||
import { PrismaTransferListingRepository } from './infrastructure/repositories/prisma-transfer-listing.repository';
|
||||
import { CLAUDE_VISION_SERVICE, ClaudeVisionService } from './infrastructure/services/claude-vision.service';
|
||||
import { TypesenseTransferService } from './infrastructure/services/typesense-transfer.service';
|
||||
import { TransferController } from './presentation/controllers/transfer.controller';
|
||||
|
||||
const CommandHandlers = [
|
||||
CreateTransferListingHandler,
|
||||
EstimateFromPhotosHandler,
|
||||
EstimateTransferPricesHandler,
|
||||
UpdateTransferListingHandler,
|
||||
];
|
||||
@@ -29,6 +32,7 @@ const QueryHandlers = [
|
||||
controllers: [TransferController],
|
||||
providers: [
|
||||
{ provide: TRANSFER_LISTING_REPOSITORY, useClass: PrismaTransferListingRepository },
|
||||
{ provide: CLAUDE_VISION_SERVICE, useClass: ClaudeVisionService },
|
||||
TypesenseTransferService,
|
||||
...CommandHandlers,
|
||||
...QueryHandlers,
|
||||
|
||||
Reference in New Issue
Block a user