diff --git a/apps/api/src/modules/transfer/application/__tests__/estimate-from-photos.handler.spec.ts b/apps/api/src/modules/transfer/application/__tests__/estimate-from-photos.handler.spec.ts new file mode 100644 index 0000000..e638d6c --- /dev/null +++ b/apps/api/src/modules/transfer/application/__tests__/estimate-from-photos.handler.spec.ts @@ -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 { + 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 }; + + 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' }], + }); + }); +}); diff --git a/apps/api/src/modules/transfer/application/commands/estimate-from-photos/estimate-from-photos.command.ts b/apps/api/src/modules/transfer/application/commands/estimate-from-photos/estimate-from-photos.command.ts new file mode 100644 index 0000000..e122356 --- /dev/null +++ b/apps/api/src/modules/transfer/application/commands/estimate-from-photos/estimate-from-photos.command.ts @@ -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[], + ) {} +} diff --git a/apps/api/src/modules/transfer/application/commands/estimate-from-photos/estimate-from-photos.handler.ts b/apps/api/src/modules/transfer/application/commands/estimate-from-photos/estimate-from-photos.handler.ts new file mode 100644 index 0000000..fac4629 --- /dev/null +++ b/apps/api/src/modules/transfer/application/commands/estimate-from-photos/estimate-from-photos.handler.ts @@ -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 { + constructor( + @Inject(CLAUDE_VISION_SERVICE) private readonly visionService: IClaudeVisionService, + private readonly logger: LoggerService, + ) {} + + async execute(command: EstimateFromPhotosCommand): Promise { + 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 { + // 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, + }; + } +} diff --git a/apps/api/src/modules/transfer/application/commands/estimate-from-photos/index.ts b/apps/api/src/modules/transfer/application/commands/estimate-from-photos/index.ts new file mode 100644 index 0000000..f6d94c5 --- /dev/null +++ b/apps/api/src/modules/transfer/application/commands/estimate-from-photos/index.ts @@ -0,0 +1,2 @@ +export { EstimateFromPhotosCommand } from './estimate-from-photos.command'; +export { EstimateFromPhotosHandler } from './estimate-from-photos.handler'; diff --git a/apps/api/src/modules/transfer/infrastructure/__tests__/claude-vision.service.spec.ts b/apps/api/src/modules/transfer/infrastructure/__tests__/claude-vision.service.spec.ts new file mode 100644 index 0000000..3435895 --- /dev/null +++ b/apps/api/src/modules/transfer/infrastructure/__tests__/claude-vision.service.spec.ts @@ -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 }; + let mockCache: { + getOrSet: ReturnType; + }; + + 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; + + 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' }), + ]), + }, + ], + }), + ); + }); + }); +}); diff --git a/apps/api/src/modules/transfer/infrastructure/services/claude-vision.service.ts b/apps/api/src/modules/transfer/infrastructure/services/claude-vision.service.ts new file mode 100644 index 0000000..000d91a --- /dev/null +++ b/apps/api/src/modules/transfer/infrastructure/services/claude-vision.service.ts @@ -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; + 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('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 { + 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; + + 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 = { + 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 = { + 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 { + try { + return await this.cache.getOrSet( + 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 { + 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" +}`; diff --git a/apps/api/src/modules/transfer/presentation/controllers/transfer.controller.ts b/apps/api/src/modules/transfer/presentation/controllers/transfer.controller.ts index f4a4e00..433512e 100644 --- a/apps/api/src/modules/transfer/presentation/controllers/transfer.controller.ts +++ b/apps/api/src/modules/transfer/presentation/controllers/transfer.controller.ts @@ -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' }) diff --git a/apps/api/src/modules/transfer/presentation/dto/estimate-from-photos.dto.ts b/apps/api/src/modules/transfer/presentation/dto/estimate-from-photos.dto.ts new file mode 100644 index 0000000..1609aa6 --- /dev/null +++ b/apps/api/src/modules/transfer/presentation/dto/estimate-from-photos.dto.ts @@ -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[]; +} diff --git a/apps/api/src/modules/transfer/transfer.module.ts b/apps/api/src/modules/transfer/transfer.module.ts index aecdc67..5bd78d4 100644 --- a/apps/api/src/modules/transfer/transfer.module.ts +++ b/apps/api/src/modules/transfer/transfer.module.ts @@ -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,