Spaces:
Running
Running
Commit ·
610766f
1
Parent(s): ec30ed5
fyp
Browse files- IMPLEMENTATION_SUMMARY.md +319 -0
- LISTING_METHODS_VISUAL_GUIDE.md +358 -0
- VISION_FEATURE_INTEGRATION_GUIDE.md +794 -0
- app/ai/agent/nodes/listing_collect.py +76 -0
- app/ai/services/vision_service.py +424 -0
- app/config.py +14 -0
- app/routes/media_upload.py +507 -0
- main.py +2 -0
- requirements.txt +4 -0
IMPLEMENTATION_SUMMARY.md
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🎯 Vision AI Listing Feature - Implementation Summary
|
| 2 |
+
|
| 3 |
+
## What Was Built
|
| 4 |
+
|
| 5 |
+
A **smart AI-powered property listing feature** that intelligently handles THREE different listing methods and produces a unified result.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Key Features Implemented
|
| 10 |
+
|
| 11 |
+
### 1. ✅ Smart Listing Method Detection
|
| 12 |
+
|
| 13 |
+
The system knows HOW the user is listing and behaves accordingly:
|
| 14 |
+
|
| 15 |
+
**TEXT Method** (User provides details via chat)
|
| 16 |
+
- User says: "3-bed, 2-bath in Lagos, 500k/month, has WiFi, AC"
|
| 17 |
+
- Uploads photos for VALIDATION (not re-extraction)
|
| 18 |
+
- Backend: Validates images are property-related, uploads to Cloudflare
|
| 19 |
+
- Result: Text data + validated photos
|
| 20 |
+
|
| 21 |
+
**IMAGE Method** (User uploads photos only)
|
| 22 |
+
- User just uploads photos (no text details)
|
| 23 |
+
- Backend: EXTRACTS all details from images (bedrooms, bathrooms, amenities)
|
| 24 |
+
- Generates: SHORT title (max 2 sentences) + full description
|
| 25 |
+
- Result: Complete listing data extracted from photos
|
| 26 |
+
|
| 27 |
+
**VIDEO Method** (User uploads video + photos)
|
| 28 |
+
- User uploads video walkthrough
|
| 29 |
+
- Backend: Uploads to Cloudinary, suggests adding photos
|
| 30 |
+
- User uploads photos for analysis
|
| 31 |
+
- Backend: Extracts details from photos (same as IMAGE method)
|
| 32 |
+
- Result: Full data from photos + video URL
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
### 2. ✅ Intelligent Title & Description Generation
|
| 37 |
+
|
| 38 |
+
**Title Requirements:**
|
| 39 |
+
- ✅ SHORT - Maximum 2 sentences
|
| 40 |
+
- ✅ Examples: "Modern 3-bed apartment. Great location!"
|
| 41 |
+
- ❌ NOT: Long descriptions with many details
|
| 42 |
+
|
| 43 |
+
**Description:**
|
| 44 |
+
- Full 2-3 sentence description of property
|
| 45 |
+
- Professional tone
|
| 46 |
+
- Highlights key features
|
| 47 |
+
|
| 48 |
+
**Both generated by Vision AI** for image/video methods
|
| 49 |
+
|
| 50 |
+
---
|
| 51 |
+
|
| 52 |
+
### 3. ✅ Smart File Naming Strategy
|
| 53 |
+
|
| 54 |
+
**Pattern:** `{location}_{title}_{timestamp}_{index}.jpg`
|
| 55 |
+
|
| 56 |
+
**Example filenames:**
|
| 57 |
+
- `Lagos_Modern_Apartment_2025_01_31_0.jpg`
|
| 58 |
+
- `Victoria_Island_3_Bed_Luxury_2025_01_31_1.jpg`
|
| 59 |
+
- `Cotonou_Cozy_Studio_2025_01_31_0.jpg`
|
| 60 |
+
|
| 61 |
+
**Benefits:**
|
| 62 |
+
- Easy to identify property in storage
|
| 63 |
+
- Shows when listed (timestamp)
|
| 64 |
+
- Automatically indexed for multiple photos
|
| 65 |
+
- Cloudflare worker detects duplicates and appends numbers
|
| 66 |
+
|
| 67 |
+
---
|
| 68 |
+
|
| 69 |
+
### 4. ✅ Unified Response Format
|
| 70 |
+
|
| 71 |
+
**All three methods return the SAME structure:**
|
| 72 |
+
|
| 73 |
+
```json
|
| 74 |
+
{
|
| 75 |
+
"success": true,
|
| 76 |
+
"listing_method": "text|image|video",
|
| 77 |
+
"extracted_fields": {
|
| 78 |
+
"bedrooms": 3,
|
| 79 |
+
"bathrooms": 2,
|
| 80 |
+
"amenities": ["WiFi", "Parking", "AC"],
|
| 81 |
+
"description": "Beautiful apartment...",
|
| 82 |
+
"title": "Modern 3-Bed Apartment. Great location!"
|
| 83 |
+
},
|
| 84 |
+
"confidence": {
|
| 85 |
+
"bedrooms": 0.95,
|
| 86 |
+
"bathrooms": 0.88,
|
| 87 |
+
"amenities": 0.72,
|
| 88 |
+
"title": 0.85
|
| 89 |
+
},
|
| 90 |
+
"image_urls": ["url1", "url2"],
|
| 91 |
+
"video_url": "https://cloudinary..." // Only if video method
|
| 92 |
+
}
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
**Frontend shows same UI** regardless of how user listed → Same draft card, same editing experience
|
| 96 |
+
|
| 97 |
+
---
|
| 98 |
+
|
| 99 |
+
### 5. ✅ Property Validation BEFORE Upload
|
| 100 |
+
|
| 101 |
+
**Critical feature for space saving:**
|
| 102 |
+
|
| 103 |
+
```
|
| 104 |
+
Image Upload Flow:
|
| 105 |
+
1. Receive image from frontend
|
| 106 |
+
2. Check: "Is this a property image?"
|
| 107 |
+
3. If NO → Reject with message, no upload
|
| 108 |
+
4. If YES → Upload to Cloudflare with smart filename
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
This prevents non-property images from consuming Cloudflare storage!
|
| 112 |
+
|
| 113 |
+
---
|
| 114 |
+
|
| 115 |
+
### 6. ✅ Vision Service Enhancements
|
| 116 |
+
|
| 117 |
+
**New capabilities in `vision_service.py`:**
|
| 118 |
+
|
| 119 |
+
- `extract_property_fields()` - Now generates title + description
|
| 120 |
+
- `_generate_title()` - Creates SHORT titles (max 2 sentences)
|
| 121 |
+
- `_extract_room_count()` - Counts bedrooms/bathrooms
|
| 122 |
+
- `_detect_amenities()` - Finds amenities in images
|
| 123 |
+
- `_generate_description()` - Creates full descriptions
|
| 124 |
+
- `merge_multiple_image_results()` - Combines results from multiple images
|
| 125 |
+
- Confidence scoring for each field
|
| 126 |
+
|
| 127 |
+
---
|
| 128 |
+
|
| 129 |
+
### 7. ✅ Enhanced Media Upload Routes
|
| 130 |
+
|
| 131 |
+
**Updated endpoints:**
|
| 132 |
+
|
| 133 |
+
`POST /listings/analyze-images`
|
| 134 |
+
- Accepts `listing_method` parameter ("text", "image", "video")
|
| 135 |
+
- Accepts optional `location` parameter for context
|
| 136 |
+
- Returns: Complete extracted fields + image URLs + confidence scores
|
| 137 |
+
- Generates intelligent filenames during upload
|
| 138 |
+
|
| 139 |
+
`POST /listings/analyze-video`
|
| 140 |
+
- Uploads video to Cloudinary with smart naming
|
| 141 |
+
- Returns: Video URL + suggestions to upload photos
|
| 142 |
+
- Recommends photos for better accuracy
|
| 143 |
+
|
| 144 |
+
---
|
| 145 |
+
|
| 146 |
+
## Files Modified/Created
|
| 147 |
+
|
| 148 |
+
### Created Files:
|
| 149 |
+
1. **`app/ai/services/vision_service.py`** - Vision AI analysis service
|
| 150 |
+
2. **`app/routes/media_upload.py`** - Image/video upload endpoints
|
| 151 |
+
3. **`VISION_FEATURE_INTEGRATION_GUIDE.md`** - Complete integration guide
|
| 152 |
+
4. **`IMPLEMENTATION_SUMMARY.md`** - This file
|
| 153 |
+
|
| 154 |
+
### Modified Files:
|
| 155 |
+
1. **`app/config.py`** - Added Cloudinary + Vision settings
|
| 156 |
+
2. **`requirements.txt`** - Added cloudinary + ffmpeg-python
|
| 157 |
+
3. **`app/ai/agent/nodes/listing_collect.py`** - Added `initialize_from_vision_analysis()` function
|
| 158 |
+
4. **`main.py`** - Registered media_upload routes
|
| 159 |
+
|
| 160 |
+
---
|
| 161 |
+
|
| 162 |
+
## Configuration Required
|
| 163 |
+
|
| 164 |
+
Add to `.env`:
|
| 165 |
+
|
| 166 |
+
```bash
|
| 167 |
+
# Cloudinary (Video Storage)
|
| 168 |
+
CLOUDINARY_CLOUD_NAME=your_cloud_name
|
| 169 |
+
CLOUDINARY_API_KEY=your_api_key
|
| 170 |
+
CLOUDINARY_API_SECRET=your_api_secret
|
| 171 |
+
|
| 172 |
+
# Hugging Face Vision Model
|
| 173 |
+
HF_TOKEN=your_hf_token
|
| 174 |
+
HF_VISION_MODEL=vikhyatk/moondream2
|
| 175 |
+
HF_VISION_API_ENABLED=true
|
| 176 |
+
PROPERTY_IMAGE_MIN_CONFIDENCE=0.6
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
---
|
| 180 |
+
|
| 181 |
+
## Frontend Changes Required
|
| 182 |
+
|
| 183 |
+
### Update Image Upload Flow
|
| 184 |
+
|
| 185 |
+
**OLD (Direct to Cloudflare):**
|
| 186 |
+
```javascript
|
| 187 |
+
// Upload directly to Cloudflare
|
| 188 |
+
const url = await uploadToCloudflare(image)
|
| 189 |
+
```
|
| 190 |
+
|
| 191 |
+
**NEW (Via Backend with Validation):**
|
| 192 |
+
```javascript
|
| 193 |
+
// Method 1: Text listing (chat + photos)
|
| 194 |
+
const result = await fetch('/listings/analyze-images', {
|
| 195 |
+
method: 'POST',
|
| 196 |
+
body: formData,
|
| 197 |
+
headers: { 'listing_method': 'text', 'location': chatLocation }
|
| 198 |
+
})
|
| 199 |
+
|
| 200 |
+
// Method 2: Image listing (photos only)
|
| 201 |
+
const result = await fetch('/listings/analyze-images', {
|
| 202 |
+
method: 'POST',
|
| 203 |
+
body: formData,
|
| 204 |
+
headers: { 'listing_method': 'image' }
|
| 205 |
+
})
|
| 206 |
+
|
| 207 |
+
// Method 3: Video listing
|
| 208 |
+
const result = await fetch('/listings/analyze-video', {
|
| 209 |
+
method: 'POST',
|
| 210 |
+
body: formData,
|
| 211 |
+
headers: { 'listing_method': 'video' }
|
| 212 |
+
})
|
| 213 |
+
```
|
| 214 |
+
|
| 215 |
+
---
|
| 216 |
+
|
| 217 |
+
## User Experience Flow
|
| 218 |
+
|
| 219 |
+
### For Image Listing Method:
|
| 220 |
+
|
| 221 |
+
```
|
| 222 |
+
User clicks "List with Photos" → Uploads 2-3 images
|
| 223 |
+
↓
|
| 224 |
+
Backend validates images are property-related
|
| 225 |
+
↓
|
| 226 |
+
AI extracts:
|
| 227 |
+
- Bedrooms: 3 (confidence: 95%)
|
| 228 |
+
- Bathrooms: 2 (confidence: 88%)
|
| 229 |
+
- Amenities: WiFi, AC, Parking, Pool
|
| 230 |
+
- Title: "Modern 3-Bed Apartment. Great location!" (SHORT)
|
| 231 |
+
- Description: "Beautiful 3-bed with modern furnishings..."
|
| 232 |
+
↓
|
| 233 |
+
Shows Draft UI with:
|
| 234 |
+
- Photos with smart names (Lagos_Modern_Apartment_2025_01_31_0.jpg)
|
| 235 |
+
- Extracted fields
|
| 236 |
+
- Confidence indicators
|
| 237 |
+
↓
|
| 238 |
+
User asked: "What's the location, address, and price?"
|
| 239 |
+
↓
|
| 240 |
+
User provides: "Lagos, Victoria Island, 500,000 per month"
|
| 241 |
+
↓
|
| 242 |
+
AI infers listing_type: "rent" (from price context)
|
| 243 |
+
↓
|
| 244 |
+
User edits via text:
|
| 245 |
+
- "Change amenities to WiFi, gym, and pool"
|
| 246 |
+
- "Update title to something catchier"
|
| 247 |
+
↓
|
| 248 |
+
User publishes: "Publish this listing"
|
| 249 |
+
↓
|
| 250 |
+
Listing created with all auto-detected + user-provided data
|
| 251 |
+
```
|
| 252 |
+
|
| 253 |
+
---
|
| 254 |
+
|
| 255 |
+
## Key Differences from Previous Design
|
| 256 |
+
|
| 257 |
+
| Aspect | Before | Now |
|
| 258 |
+
|--------|--------|-----|
|
| 259 |
+
| **File naming** | Random/original names | Smart names (location_title_date) |
|
| 260 |
+
| **Title generation** | Not generated for images | AI generates SHORT titles (max 2 sentences) |
|
| 261 |
+
| **Listing methods** | Only text-based | Three methods: text, image, video |
|
| 262 |
+
| **Method detection** | N/A | AI knows how user is listing |
|
| 263 |
+
| **Video storage** | N/A | Cloudinary for videos |
|
| 264 |
+
| **Upload strategy** | Direct to Cloudflare | Backend validates first (saves space) |
|
| 265 |
+
| **Confidence scores** | Not implemented | Per-field confidence for each extraction |
|
| 266 |
+
|
| 267 |
+
---
|
| 268 |
+
|
| 269 |
+
## Performance Notes
|
| 270 |
+
|
| 271 |
+
**Vision API Response Times:**
|
| 272 |
+
- Image validation: 2-3 seconds (first image), +1s per additional
|
| 273 |
+
- Field extraction: 2-4 seconds per image
|
| 274 |
+
- Title generation: 1-2 seconds per image
|
| 275 |
+
- Video upload: 5-10 seconds (depends on file size)
|
| 276 |
+
|
| 277 |
+
**Cost Optimization:**
|
| 278 |
+
- Only valid property images uploaded (rejects non-property images early)
|
| 279 |
+
- Smaller file sizes with smart naming
|
| 280 |
+
- Cloudflare worker deduplicates files
|
| 281 |
+
- Hugging Face Inference API used (cheaper than self-hosted)
|
| 282 |
+
|
| 283 |
+
---
|
| 284 |
+
|
| 285 |
+
## Testing Checklist
|
| 286 |
+
|
| 287 |
+
- [ ] Test TEXT method: Chat + upload images
|
| 288 |
+
- [ ] Test IMAGE method: Upload images only
|
| 289 |
+
- [ ] Test VIDEO method: Upload video + photos
|
| 290 |
+
- [ ] Verify short titles generated (max 2 sentences)
|
| 291 |
+
- [ ] Verify descriptions generated (full, not short)
|
| 292 |
+
- [ ] Verify file naming is intelligent (location_title_date)
|
| 293 |
+
- [ ] Verify property validation rejects non-property images
|
| 294 |
+
- [ ] Verify confidence scores are returned
|
| 295 |
+
- [ ] Verify all three methods produce same draft UI
|
| 296 |
+
- [ ] Test editing via natural language commands
|
| 297 |
+
- [ ] Test publishing with all three methods
|
| 298 |
+
|
| 299 |
+
---
|
| 300 |
+
|
| 301 |
+
## Next Steps
|
| 302 |
+
|
| 303 |
+
1. **Frontend Integration** - Update image/video upload flows
|
| 304 |
+
2. **Test All Three Methods** - Verify each method works end-to-end
|
| 305 |
+
3. **Monitor Accuracy** - Track field extraction accuracy metrics
|
| 306 |
+
4. **Optimize Prompts** - Fine-tune Vision AI prompts based on real data
|
| 307 |
+
5. **User Feedback** - Gather feedback on titles/descriptions
|
| 308 |
+
6. **Enhance Features** - Add OCR for address extraction, price suggestions, etc.
|
| 309 |
+
|
| 310 |
+
---
|
| 311 |
+
|
| 312 |
+
## Support
|
| 313 |
+
|
| 314 |
+
See `VISION_FEATURE_INTEGRATION_GUIDE.md` for:
|
| 315 |
+
- Detailed API documentation
|
| 316 |
+
- Complete example code
|
| 317 |
+
- Error handling
|
| 318 |
+
- Troubleshooting
|
| 319 |
+
- Future enhancements
|
LISTING_METHODS_VISUAL_GUIDE.md
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 📊 Three Listing Methods - Visual Guide
|
| 2 |
+
|
| 3 |
+
## Method Comparison
|
| 4 |
+
|
| 5 |
+
```
|
| 6 |
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
| 7 |
+
│ TEXT vs IMAGE vs VIDEO │
|
| 8 |
+
└─────────────────────────────────────────────────────────────────────────────┘
|
| 9 |
+
|
| 10 |
+
METHOD 1: TEXT
|
| 11 |
+
═════════════════════════════════════════════════════════════════════════════
|
| 12 |
+
User Flow:
|
| 13 |
+
User Types: "3-bed, 2-bath, Lagos, 500k/month, has WiFi, AC"
|
| 14 |
+
↓
|
| 15 |
+
AI Extracts: bedrooms=3, bathrooms=2, location=Lagos, price=500000, etc.
|
| 16 |
+
↓
|
| 17 |
+
User Uploads: Images (2-3 photos)
|
| 18 |
+
↓
|
| 19 |
+
Backend: Validates images (property check) + uploads to Cloudflare
|
| 20 |
+
↓
|
| 21 |
+
Result: TEXT DATA + VALIDATED PHOTOS
|
| 22 |
+
|
| 23 |
+
Data Source:
|
| 24 |
+
├─ Bedrooms: FROM TEXT ✓
|
| 25 |
+
├─ Bathrooms: FROM TEXT ✓
|
| 26 |
+
├─ Price: FROM TEXT ✓
|
| 27 |
+
├─ Location: FROM TEXT ✓
|
| 28 |
+
├─ Title: FROM TEXT ✓
|
| 29 |
+
├─ Description: FROM TEXT ✓
|
| 30 |
+
├─ Images: VALIDATED FROM UPLOAD ✓
|
| 31 |
+
└─ Amenities: FROM TEXT OR IMAGES
|
| 32 |
+
|
| 33 |
+
UI Shows: Draft card with text-extracted data + photos
|
| 34 |
+
User Edits: "Change price to 450k", "Add gym to amenities"
|
| 35 |
+
Storage: Images → Cloudflare (smart filenames)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
METHOD 2: IMAGE
|
| 39 |
+
═════════════════════════════════════════════════════════════════════════════
|
| 40 |
+
User Flow:
|
| 41 |
+
User Clicks: "List with Photos"
|
| 42 |
+
↓
|
| 43 |
+
User Uploads: 2-5 photos (NO TEXT DATA PROVIDED)
|
| 44 |
+
↓
|
| 45 |
+
Backend:
|
| 46 |
+
├─ Validates images (property check)
|
| 47 |
+
├─ EXTRACTS bedrooms, bathrooms, amenities
|
| 48 |
+
├─ GENERATES SHORT title (max 2 sentences)
|
| 49 |
+
├─ GENERATES description
|
| 50 |
+
├─ Creates smart filenames
|
| 51 |
+
└─ Uploads to Cloudflare
|
| 52 |
+
↓
|
| 53 |
+
AI Analysis Results:
|
| 54 |
+
├─ Bedrooms: 3 (detected from images)
|
| 55 |
+
├─ Bathrooms: 2 (detected from images)
|
| 56 |
+
├─ Amenities: WiFi, AC, Parking, Pool
|
| 57 |
+
├─ Title: "Modern 3-Bed Apartment. Great location!" ← SHORT
|
| 58 |
+
├─ Description: "Beautiful apartment with modern furnishings..."
|
| 59 |
+
└─ Confidence: { bedrooms: 0.95, bathrooms: 0.88, ... }
|
| 60 |
+
↓
|
| 61 |
+
System Asks: "Location? Address? Price?"
|
| 62 |
+
↓
|
| 63 |
+
User Provides: "Lagos, Victoria Island, 500,000/month"
|
| 64 |
+
↓
|
| 65 |
+
Result: COMPLETE LISTING DATA FROM IMAGES + USER-PROVIDED INFO
|
| 66 |
+
|
| 67 |
+
Data Source:
|
| 68 |
+
├─ Bedrooms: FROM IMAGE ANALYSIS ✓
|
| 69 |
+
├─ Bathrooms: FROM IMAGE ANALYSIS ✓
|
| 70 |
+
├─ Amenities: FROM IMAGE ANALYSIS ✓
|
| 71 |
+
├─ Title: AI-GENERATED (SHORT) ✓
|
| 72 |
+
├─ Description: AI-GENERATED ✓
|
| 73 |
+
├─ Images: VALIDATED & UPLOADED ✓
|
| 74 |
+
├─ Price: USER PROVIDED ✓
|
| 75 |
+
├─ Location: USER PROVIDED ✓
|
| 76 |
+
└─ Address: USER PROVIDED ✓
|
| 77 |
+
|
| 78 |
+
UI Shows: Draft card with IMAGE-EXTRACTED data + photos
|
| 79 |
+
User Edits: "Change title", "Update amenities", "Add bedroom"
|
| 80 |
+
Storage: Images → Cloudflare with smart filenames (location_title_date.jpg)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
METHOD 3: VIDEO
|
| 84 |
+
═════════════════════════════════════════════════════════════════════════════
|
| 85 |
+
User Flow:
|
| 86 |
+
User Clicks: "List with Video"
|
| 87 |
+
↓
|
| 88 |
+
User Uploads: Video (walkthrough, 2-5 minutes)
|
| 89 |
+
↓
|
| 90 |
+
Backend: Uploads to Cloudinary with smart filename
|
| 91 |
+
↓
|
| 92 |
+
System Suggests: "Video uploaded! Please upload 2-3 photos for analysis"
|
| 93 |
+
↓
|
| 94 |
+
User Uploads: 2-3 photos
|
| 95 |
+
↓
|
| 96 |
+
Backend: EXTRACTS data from PHOTOS (same as IMAGE method)
|
| 97 |
+
↓
|
| 98 |
+
Result: DATA FROM PHOTOS + VIDEO URL
|
| 99 |
+
|
| 100 |
+
Data Source:
|
| 101 |
+
├─ Bedrooms: FROM PHOTO ANALYSIS ✓
|
| 102 |
+
├─ Bathrooms: FROM PHOTO ANALYSIS ✓
|
| 103 |
+
├─ Amenities: FROM PHOTO ANALYSIS ✓
|
| 104 |
+
├─ Title: AI-GENERATED from PHOTOS ✓
|
| 105 |
+
├─ Description: AI-GENERATED from PHOTOS ✓
|
| 106 |
+
├─ Images: FROM PHOTOS ✓
|
| 107 |
+
├─ Video: FROM UPLOADED VIDEO ✓
|
| 108 |
+
└─ Price/Location: USER PROVIDED ✓
|
| 109 |
+
|
| 110 |
+
UI Shows: Draft card with PHOTO data + video embedded
|
| 111 |
+
User Edits: Same as IMAGE method
|
| 112 |
+
Storage:
|
| 113 |
+
├─ Photos → Cloudflare (smart filenames)
|
| 114 |
+
└─ Video → Cloudinary
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
---
|
| 118 |
+
|
| 119 |
+
## File Storage Comparison
|
| 120 |
+
|
| 121 |
+
```
|
| 122 |
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
| 123 |
+
│ STORAGE LOCATIONS │
|
| 124 |
+
└────��────────────────────────────────────────────────────────────────────────┘
|
| 125 |
+
|
| 126 |
+
TEXT METHOD:
|
| 127 |
+
Images → Cloudflare (smart filename)
|
| 128 |
+
└─ Example: Lagos_Apartment_2025_01_31_0.jpg
|
| 129 |
+
|
| 130 |
+
IMAGE METHOD:
|
| 131 |
+
Photos → Cloudflare (smart filenames)
|
| 132 |
+
├─ Example: Lagos_Modern_Apartment_2025_01_31_0.jpg
|
| 133 |
+
├─ Example: Lagos_Modern_Apartment_2025_01_31_1.jpg
|
| 134 |
+
└─ Example: Lagos_Modern_Apartment_2025_01_31_2.jpg
|
| 135 |
+
|
| 136 |
+
VIDEO METHOD:
|
| 137 |
+
Photos → Cloudflare (smart filenames)
|
| 138 |
+
├─ Example: Lagos_3Bed_Apartment_2025_01_31_0.jpg
|
| 139 |
+
├─ Example: Lagos_3Bed_Apartment_2025_01_31_1.jpg
|
| 140 |
+
└─ Example: Lagos_3Bed_Apartment_2025_01_31_2.jpg
|
| 141 |
+
|
| 142 |
+
Video → Cloudinary
|
| 143 |
+
└─ Example: Lagos_Property_Video_2025_01_31_0.mp4
|
| 144 |
+
```
|
| 145 |
+
|
| 146 |
+
---
|
| 147 |
+
|
| 148 |
+
## Title & Description Details
|
| 149 |
+
|
| 150 |
+
```
|
| 151 |
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
| 152 |
+
│ TITLE (SHORT) vs DESCRIPTION (FULL) │
|
| 153 |
+
└─────────────────────────────────────────────────────────────────────────────┘
|
| 154 |
+
|
| 155 |
+
TITLE GENERATION (IMAGE/VIDEO METHOD):
|
| 156 |
+
────────────────────────────────────────
|
| 157 |
+
Format: MAX 2 SENTENCES - Keep it SHORT!
|
| 158 |
+
|
| 159 |
+
✅ GOOD Examples:
|
| 160 |
+
- "Modern 3-Bed Apartment. Great location!"
|
| 161 |
+
- "Spacious family home with garden."
|
| 162 |
+
- "Luxury studio in downtown area. Fully furnished!"
|
| 163 |
+
- "Cozy 2-bed with AC and parking. Prime location!"
|
| 164 |
+
|
| 165 |
+
❌ BAD Examples (too long):
|
| 166 |
+
- "This is a beautiful 3-bedroom, 2-bathroom modern apartment with contemporary furnishings..."
|
| 167 |
+
- "A stunning property featuring modern amenities, excellent lighting, perfect for families..."
|
| 168 |
+
|
| 169 |
+
DESCRIPTION GENERATION (IMAGE/VIDEO METHOD):
|
| 170 |
+
──────────────────────────────────────────────
|
| 171 |
+
Format: FULL 2-3 SENTENCE DESCRIPTION
|
| 172 |
+
|
| 173 |
+
Example:
|
| 174 |
+
"Beautiful 3-bedroom, 2-bathroom modern apartment featuring contemporary
|
| 175 |
+
furnishings, air conditioning, WiFi, and private balcony overlooking the
|
| 176 |
+
city. Located in a secure, gated community with excellent amenities."
|
| 177 |
+
|
| 178 |
+
TEXT METHOD:
|
| 179 |
+
────────────
|
| 180 |
+
User-provided title and description (not generated by AI)
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
---
|
| 184 |
+
|
| 185 |
+
## Smart Filename Examples
|
| 186 |
+
|
| 187 |
+
```
|
| 188 |
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
| 189 |
+
│ INTELLIGENT FILENAME GENERATION │
|
| 190 |
+
└─────────────────────────────────────────────────────────────────────────────┘
|
| 191 |
+
|
| 192 |
+
Pattern: {location}_{title}_{timestamp}_{index}.jpg
|
| 193 |
+
|
| 194 |
+
Examples with different properties:
|
| 195 |
+
──────────────────────────────────────
|
| 196 |
+
|
| 197 |
+
Property 1: 3-bed in Lagos
|
| 198 |
+
AI-Generated Title: "Modern Apartment with Pool"
|
| 199 |
+
Generated Filenames:
|
| 200 |
+
├─ Lagos_Modern_Apartment_With_Pool_2025_01_31_120530_0.jpg
|
| 201 |
+
├─ Lagos_Modern_Apartment_With_Pool_2025_01_31_120530_1.jpg
|
| 202 |
+
└─ Lagos_Modern_Apartment_With_Pool_2025_01_31_120530_2.jpg
|
| 203 |
+
|
| 204 |
+
Property 2: Cozy studio in Cotonou
|
| 205 |
+
AI-Generated Title: "Affordable Studio Apartment"
|
| 206 |
+
Generated Filenames:
|
| 207 |
+
├─ Cotonou_Affordable_Studio_Apartment_2025_01_31_145000_0.jpg
|
| 208 |
+
└─ Cotonou_Affordable_Studio_Apartment_2025_01_31_145000_1.jpg
|
| 209 |
+
|
| 210 |
+
Property 3: Luxury 5-bed villa in Victoria Island
|
| 211 |
+
AI-Generated Title: "Luxury Villa with Garden"
|
| 212 |
+
Generated Filenames:
|
| 213 |
+
├─ Victoria_Island_Luxury_Villa_With_Garden_2025_01_31_090000_0.jpg
|
| 214 |
+
├─ Victoria_Island_Luxury_Villa_With_Garden_2025_01_31_090000_1.jpg
|
| 215 |
+
├─ Victoria_Island_Luxury_Villa_With_Garden_2025_01_31_090000_2.jpg
|
| 216 |
+
└─ Victoria_Island_Luxury_Villa_With_Garden_2025_01_31_090000_3.jpg
|
| 217 |
+
|
| 218 |
+
CLOUDFLARE WORKER DEDUPLICATION:
|
| 219 |
+
─────────────────────────────────
|
| 220 |
+
If same filename uploaded twice:
|
| 221 |
+
1st upload → Lagos_Modern_Apartment_With_Pool_2025_01_31_120530_0.jpg
|
| 222 |
+
2nd upload → Lagos_Modern_Apartment_With_Pool_2025_01_31_120530_0_1.jpg
|
| 223 |
+
3rd upload → Lagos_Modern_Apartment_With_Pool_2025_01_31_120530_0_2.jpg
|
| 224 |
+
```
|
| 225 |
+
|
| 226 |
+
---
|
| 227 |
+
|
| 228 |
+
## Unified Response Format
|
| 229 |
+
|
| 230 |
+
```
|
| 231 |
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
| 232 |
+
│ SAME RESPONSE FORMAT FOR ALL THREE METHODS │
|
| 233 |
+
└─────────────────────────────────────────────────────────────────────────────┘
|
| 234 |
+
|
| 235 |
+
All three methods return the EXACT SAME structure:
|
| 236 |
+
|
| 237 |
+
{
|
| 238 |
+
"success": true,
|
| 239 |
+
"listing_method": "text" | "image" | "video", ← Method identifier
|
| 240 |
+
"extracted_fields": {
|
| 241 |
+
"bedrooms": 3, ← Number or null
|
| 242 |
+
"bathrooms": 2, ← Number or null
|
| 243 |
+
"amenities": ["WiFi", "AC", "Parking"], ← Array of strings
|
| 244 |
+
"description": "Beautiful apartment...", ← Full description (2-3 sentences)
|
| 245 |
+
"title": "Modern 3-Bed. Great location!" ← SHORT title (max 2 sentences)
|
| 246 |
+
},
|
| 247 |
+
"confidence": { ← How confident AI is
|
| 248 |
+
"bedrooms": 0.95, ← 0.0 to 1.0
|
| 249 |
+
"bathrooms": 0.88,
|
| 250 |
+
"amenities": 0.72,
|
| 251 |
+
"title": 0.85,
|
| 252 |
+
"description": 0.90
|
| 253 |
+
},
|
| 254 |
+
"image_urls": [ ← Photo URLs
|
| 255 |
+
"https://imagedelivery.net/lojiz/Lagos_Modern_Apartment_2025_01_31_0/public",
|
| 256 |
+
"https://imagedelivery.net/lojiz/Lagos_Modern_Apartment_2025_01_31_1/public"
|
| 257 |
+
],
|
| 258 |
+
"video_url": "https://cloudinary.../video.mp4", ← ONLY for video method
|
| 259 |
+
"suggestions": [ ← Next steps
|
| 260 |
+
"Verify bedroom count",
|
| 261 |
+
"Upload more photos for better accuracy"
|
| 262 |
+
]
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
FRONTEND RECEIVES:
|
| 266 |
+
✓ Same structure
|
| 267 |
+
✓ Shows same UI
|
| 268 |
+
✓ Same editing experience
|
| 269 |
+
✓ Same publishing flow
|
| 270 |
+
|
| 271 |
+
Only difference: listing_method and video_url (if applicable)
|
| 272 |
+
```
|
| 273 |
+
|
| 274 |
+
---
|
| 275 |
+
|
| 276 |
+
## Decision Tree
|
| 277 |
+
|
| 278 |
+
```
|
| 279 |
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
| 280 |
+
│ HOW USER LISTS? │
|
| 281 |
+
└─────────────────────────────────────────────────────────────────────────────┘
|
| 282 |
+
|
| 283 |
+
START
|
| 284 |
+
│
|
| 285 |
+
▼
|
| 286 |
+
Does user have details?
|
| 287 |
+
/ \
|
| 288 |
+
YES NO
|
| 289 |
+
/ \
|
| 290 |
+
▼ ▼
|
| 291 |
+
Uses CHAT: Uses UPLOAD:
|
| 292 |
+
Provides Uploads
|
| 293 |
+
details via photos/video
|
| 294 |
+
text directly
|
| 295 |
+
│ │
|
| 296 |
+
│ └──→ Is it a video?
|
| 297 |
+
│ / \
|
| 298 |
+
│ YES NO
|
| 299 |
+
│ / \
|
| 300 |
+
│ ▼ ▼
|
| 301 |
+
│ VIDEO METHOD IMAGE METHOD
|
| 302 |
+
│ (upload video) (upload photos
|
| 303 |
+
│ + photos only)
|
| 304 |
+
│
|
| 305 |
+
└──────────→ Uploads photos to validate
|
| 306 |
+
│
|
| 307 |
+
▼
|
| 308 |
+
TEXT METHOD
|
| 309 |
+
(validate
|
| 310 |
+
with photos)
|
| 311 |
+
|
| 312 |
+
│
|
| 313 |
+
▼
|
| 314 |
+
BACKEND PROCESSES:
|
| 315 |
+
┌───────────────────────────────────┐
|
| 316 |
+
│ 1. Validate image (property?) │
|
| 317 |
+
│ 2. Extract/validate fields │
|
| 318 |
+
│ 3. Generate title + description │
|
| 319 |
+
│ 4. Create smart filenames │
|
| 320 |
+
│ 5. Upload to Cloudflare/Cloudinary│
|
| 321 |
+
└───────────────────────────────────┘
|
| 322 |
+
│
|
| 323 |
+
▼
|
| 324 |
+
RETURN SAME FORMAT
|
| 325 |
+
(bedrooms, bathrooms,
|
| 326 |
+
amenities, title,
|
| 327 |
+
description, images,
|
| 328 |
+
confidence, video_url)
|
| 329 |
+
│
|
| 330 |
+
▼
|
| 331 |
+
FRONTEND SHOWS
|
| 332 |
+
UNIFIED DRAFT UI
|
| 333 |
+
│
|
| 334 |
+
▼
|
| 335 |
+
USER EDITS + PUBLISHES
|
| 336 |
+
```
|
| 337 |
+
|
| 338 |
+
---
|
| 339 |
+
|
| 340 |
+
## Quick Reference
|
| 341 |
+
|
| 342 |
+
```
|
| 343 |
+
METHOD INPUT EXTRACTION OUTPUT
|
| 344 |
+
═════════════════════════��═══════════════════════════════════════════
|
| 345 |
+
TEXT Text details From text Complete data
|
| 346 |
+
+ photos + validate from text
|
| 347 |
+
photos + photos
|
| 348 |
+
|
| 349 |
+
IMAGE Photos only From images Complete data
|
| 350 |
+
(no text) (AI analyzes) from images
|
| 351 |
+
|
| 352 |
+
VIDEO Video From photos Complete data
|
| 353 |
+
+ photos (AI analyzes) from photos
|
| 354 |
+
+ video URL
|
| 355 |
+
═════════════════════════════════════════════════════════════════════
|
| 356 |
+
|
| 357 |
+
COMMON THEME: All produce same result → same UI → same experience
|
| 358 |
+
```
|
VISION_FEATURE_INTEGRATION_GUIDE.md
ADDED
|
@@ -0,0 +1,794 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🤖 AI-Powered Property Listing with Image/Video Analysis
|
| 2 |
+
## Integration Guide
|
| 3 |
+
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
## Overview
|
| 7 |
+
|
| 8 |
+
This document explains how to integrate the new **Vision AI feature** that allows users to list properties by uploading images or videos. The AI automatically detects property details (bedrooms, bathrooms, amenities) and fills listing fields.
|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
## Architecture
|
| 13 |
+
|
| 14 |
+
### Flow Diagram
|
| 15 |
+
|
| 16 |
+
```
|
| 17 |
+
USER UPLOADS IMAGES/VIDEO
|
| 18 |
+
↓
|
| 19 |
+
[BACKEND IMAGE VALIDATION]
|
| 20 |
+
- Check if image is property-related (BEFORE upload)
|
| 21 |
+
- Reject non-property images (saves Cloudflare space)
|
| 22 |
+
↓
|
| 23 |
+
[VISION AI ANALYSIS] (Hugging Face Inference API)
|
| 24 |
+
- Extract bedrooms, bathrooms
|
| 25 |
+
- Detect amenities
|
| 26 |
+
- Generate description
|
| 27 |
+
- Return confidence scores
|
| 28 |
+
↓
|
| 29 |
+
[UPLOAD TO CLOUD STORAGE]
|
| 30 |
+
- Images → Cloudflare (only if validated)
|
| 31 |
+
- Videos → Cloudinary
|
| 32 |
+
↓
|
| 33 |
+
[INITIALIZE LISTING]
|
| 34 |
+
- Pre-fill extracted fields
|
| 35 |
+
- Ask user for uncertain/missing fields (price, location, address)
|
| 36 |
+
↓
|
| 37 |
+
[DRAFT UI]
|
| 38 |
+
- Show preview card like text-based flow
|
| 39 |
+
↓
|
| 40 |
+
[USER REVIEWS & EDITS]
|
| 41 |
+
- Edit via natural language commands
|
| 42 |
+
↓
|
| 43 |
+
[PUBLISH]
|
| 44 |
+
- Same as text-based flow
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
---
|
| 48 |
+
|
| 49 |
+
## New Files Created
|
| 50 |
+
|
| 51 |
+
### 1. **Vision Service** - `app/ai/services/vision_service.py`
|
| 52 |
+
|
| 53 |
+
**Purpose**: Analyzes images/videos using Hugging Face Inference API
|
| 54 |
+
|
| 55 |
+
**Key Classes**:
|
| 56 |
+
```python
|
| 57 |
+
class VisionService:
|
| 58 |
+
def validate_property_image(image_bytes) → (bool, float, str)
|
| 59 |
+
def extract_property_fields(image_bytes) → Dict
|
| 60 |
+
def merge_multiple_image_results(results_list) → Dict
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
**Functions**:
|
| 64 |
+
- `validate_property_image()` - Check if image is property-related (BEFORE upload)
|
| 65 |
+
- `extract_property_fields()` - Extract bedrooms, bathrooms, amenities, description
|
| 66 |
+
- `_extract_room_count()` - Count rooms
|
| 67 |
+
- `_detect_amenities()` - Find amenities
|
| 68 |
+
- `_generate_description()` - Create property description
|
| 69 |
+
- `merge_multiple_image_results()` - Combine results from multiple images
|
| 70 |
+
|
| 71 |
+
---
|
| 72 |
+
|
| 73 |
+
### 2. **Media Upload Routes** - `app/routes/media_upload.py`
|
| 74 |
+
|
| 75 |
+
**Purpose**: Handle image/video uploads with validation
|
| 76 |
+
|
| 77 |
+
**Endpoints**:
|
| 78 |
+
|
| 79 |
+
#### `POST /listings/analyze-images`
|
| 80 |
+
```
|
| 81 |
+
Request:
|
| 82 |
+
- files: List of image files (max 10, max 10MB each)
|
| 83 |
+
- listing_method: "text" | "image" | "video" (how user is listing)
|
| 84 |
+
- location: Optional string (context from text method)
|
| 85 |
+
|
| 86 |
+
Process:
|
| 87 |
+
1. Validate image format (JPEG, PNG, WebP)
|
| 88 |
+
2. Validate image is property-related (BEFORE upload)
|
| 89 |
+
3. Extract property fields
|
| 90 |
+
4. Upload to Cloudflare (only if validated)
|
| 91 |
+
5. Return extracted fields + image URLs
|
| 92 |
+
|
| 93 |
+
Response:
|
| 94 |
+
{
|
| 95 |
+
"success": true,
|
| 96 |
+
"images_processed": 2,
|
| 97 |
+
"images_validated": ["image1.jpg", "image2.jpg"],
|
| 98 |
+
"image_urls": [
|
| 99 |
+
"https://cloudflare.../image1.jpg",
|
| 100 |
+
"https://cloudflare.../image2.jpg"
|
| 101 |
+
],
|
| 102 |
+
"extracted_fields": {
|
| 103 |
+
"bedrooms": 3,
|
| 104 |
+
"bathrooms": 2,
|
| 105 |
+
"amenities": ["WiFi", "Parking", "AC"],
|
| 106 |
+
"description": "Spacious modern apartment..."
|
| 107 |
+
},
|
| 108 |
+
"confidence": {
|
| 109 |
+
"bedrooms": 0.95,
|
| 110 |
+
"bathrooms": 0.88,
|
| 111 |
+
"amenities": 0.72,
|
| 112 |
+
"description": 0.91
|
| 113 |
+
},
|
| 114 |
+
"validation_errors": [],
|
| 115 |
+
"suggestions": ["Verify bedroom count", "...]
|
| 116 |
+
}
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
#### `POST /listings/analyze-video`
|
| 120 |
+
```
|
| 121 |
+
Request:
|
| 122 |
+
- video: Single video file (max 100MB)
|
| 123 |
+
|
| 124 |
+
Response:
|
| 125 |
+
{
|
| 126 |
+
"success": true,
|
| 127 |
+
"video_url": "https://res.cloudinary.com/.../video.mp4",
|
| 128 |
+
"message": "Video uploaded. Photos recommended for better accuracy.",
|
| 129 |
+
"extracted_fields": {...},
|
| 130 |
+
"suggestions": ["Upload property photos for better detection"]
|
| 131 |
+
}
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
#### `POST /listings/validate-media`
|
| 135 |
+
```
|
| 136 |
+
Quick validation without uploading
|
| 137 |
+
Returns: Validation results for each file
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
---
|
| 141 |
+
|
| 142 |
+
### 3. **Listing Collection Integration** - `app/ai/agent/nodes/listing_collect.py`
|
| 143 |
+
|
| 144 |
+
**New Function**: `initialize_from_vision_analysis(state, vision_data)`
|
| 145 |
+
|
| 146 |
+
**Purpose**: Pre-populate listing state with AI-detected fields
|
| 147 |
+
|
| 148 |
+
**Usage**:
|
| 149 |
+
```python
|
| 150 |
+
# After user uploads images and AI analyzes them
|
| 151 |
+
state = await initialize_from_vision_analysis(state, vision_data)
|
| 152 |
+
# State now has bedrooms, bathrooms, amenities, images, description pre-filled
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
---
|
| 156 |
+
|
| 157 |
+
## Configuration
|
| 158 |
+
|
| 159 |
+
### Add to `.env`
|
| 160 |
+
|
| 161 |
+
```bash
|
| 162 |
+
# Cloudinary (Video Storage)
|
| 163 |
+
CLOUDINARY_CLOUD_NAME=your_cloud_name
|
| 164 |
+
CLOUDINARY_API_KEY=your_api_key
|
| 165 |
+
CLOUDINARY_API_SECRET=your_api_secret
|
| 166 |
+
|
| 167 |
+
# Hugging Face Vision Model
|
| 168 |
+
HF_TOKEN=your_hf_token
|
| 169 |
+
HF_VISION_MODEL=vikhyatk/moondream2
|
| 170 |
+
HF_VISION_API_ENABLED=true
|
| 171 |
+
PROPERTY_IMAGE_MIN_CONFIDENCE=0.6
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
### Update `app/config.py` ✅ (Already Done)
|
| 175 |
+
|
| 176 |
+
Added:
|
| 177 |
+
- `CLOUDINARY_CLOUD_NAME`
|
| 178 |
+
- `CLOUDINARY_API_KEY`
|
| 179 |
+
- `CLOUDINARY_API_SECRET`
|
| 180 |
+
- `HF_VISION_MODEL`
|
| 181 |
+
- `HF_VISION_API_ENABLED`
|
| 182 |
+
- `PROPERTY_IMAGE_MIN_CONFIDENCE`
|
| 183 |
+
|
| 184 |
+
### Update `requirements.txt` ✅ (Already Done)
|
| 185 |
+
|
| 186 |
+
Added:
|
| 187 |
+
- `cloudinary>=1.40.0`
|
| 188 |
+
- `ffmpeg-python>=0.2.1`
|
| 189 |
+
|
| 190 |
+
---
|
| 191 |
+
|
| 192 |
+
## Frontend Integration
|
| 193 |
+
|
| 194 |
+
### Frontend Responsibilities
|
| 195 |
+
|
| 196 |
+
**IMPORTANT**: Images must now be uploaded to the **backend** (not directly to Cloudflare)
|
| 197 |
+
|
| 198 |
+
#### 1. **Image Upload Flow**
|
| 199 |
+
|
| 200 |
+
```typescript
|
| 201 |
+
// OLD (Direct to Cloudflare) - DEPRECATED
|
| 202 |
+
POST to Cloudflare directly
|
| 203 |
+
|
| 204 |
+
// NEW (Via Backend with Validation) - REQUIRED
|
| 205 |
+
POST /listings/analyze-images
|
| 206 |
+
Headers: Authorization: Bearer {token}
|
| 207 |
+
Body: FormData with files
|
| 208 |
+
Response: Extracted fields + image URLs
|
| 209 |
+
```
|
| 210 |
+
|
| 211 |
+
#### 2. **Example Frontend Code**
|
| 212 |
+
|
| 213 |
+
**For TEXT method** (user provided details via chat):
|
| 214 |
+
```typescript
|
| 215 |
+
async function uploadImagesForTextListing(files: File[], location: string) {
|
| 216 |
+
const formData = new FormData()
|
| 217 |
+
files.forEach(file => formData.append('images', file))
|
| 218 |
+
formData.append('listing_method', 'text')
|
| 219 |
+
formData.append('location', location) // Context from text conversation
|
| 220 |
+
|
| 221 |
+
const response = await fetch('/listings/analyze-images', {
|
| 222 |
+
method: 'POST',
|
| 223 |
+
headers: { 'Authorization': `Bearer ${token}` },
|
| 224 |
+
body: formData
|
| 225 |
+
})
|
| 226 |
+
|
| 227 |
+
const result = await response.json()
|
| 228 |
+
|
| 229 |
+
if (!result.success) {
|
| 230 |
+
result.validation_errors.forEach(err => {
|
| 231 |
+
alert(`${err.image}: ${err.error}`)
|
| 232 |
+
})
|
| 233 |
+
return
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
// Images validated with text-provided data
|
| 237 |
+
showListingDraft({
|
| 238 |
+
// Use data from CHAT (text-provided), images as validation
|
| 239 |
+
bedrooms: result.extracted_fields.bedrooms,
|
| 240 |
+
bathrooms: result.extracted_fields.bathrooms,
|
| 241 |
+
images: result.image_urls,
|
| 242 |
+
})
|
| 243 |
+
}
|
| 244 |
+
```
|
| 245 |
+
|
| 246 |
+
**For IMAGE method** (user uploading photos only):
|
| 247 |
+
```typescript
|
| 248 |
+
async function uploadImagesForPhotListing(files: File[]) {
|
| 249 |
+
const formData = new FormData()
|
| 250 |
+
files.forEach(file => formData.append('images', file))
|
| 251 |
+
formData.append('listing_method', 'image')
|
| 252 |
+
// No location - we'll extract everything from images
|
| 253 |
+
|
| 254 |
+
const response = await fetch('/listings/analyze-images', {
|
| 255 |
+
method: 'POST',
|
| 256 |
+
headers: { 'Authorization': `Bearer ${token}` },
|
| 257 |
+
body: formData
|
| 258 |
+
})
|
| 259 |
+
|
| 260 |
+
const result = await response.json()
|
| 261 |
+
|
| 262 |
+
if (!result.success) {
|
| 263 |
+
result.validation_errors.forEach(err => {
|
| 264 |
+
alert(`${err.image}: ${err.error}`)
|
| 265 |
+
})
|
| 266 |
+
return
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
// Show extracted fields (AI analyzed images)
|
| 270 |
+
showListingDraft({
|
| 271 |
+
title: result.extracted_fields.title, // AI-generated SHORT title
|
| 272 |
+
description: result.extracted_fields.description, // AI-generated description
|
| 273 |
+
bedrooms: result.extracted_fields.bedrooms,
|
| 274 |
+
bathrooms: result.extracted_fields.bathrooms,
|
| 275 |
+
amenities: result.extracted_fields.amenities,
|
| 276 |
+
images: result.image_urls,
|
| 277 |
+
confidence: result.confidence
|
| 278 |
+
})
|
| 279 |
+
}
|
| 280 |
+
```
|
| 281 |
+
|
| 282 |
+
**For VIDEO method**:
|
| 283 |
+
```typescript
|
| 284 |
+
async function uploadVideoForListing(videoFile: File, location?: string) {
|
| 285 |
+
const formData = new FormData()
|
| 286 |
+
formData.append('video', videoFile)
|
| 287 |
+
if (location) formData.append('location', location)
|
| 288 |
+
|
| 289 |
+
const response = await fetch('/listings/analyze-video', {
|
| 290 |
+
method: 'POST',
|
| 291 |
+
headers: { 'Authorization': `Bearer ${token}` },
|
| 292 |
+
body: formData
|
| 293 |
+
})
|
| 294 |
+
|
| 295 |
+
const result = await response.json()
|
| 296 |
+
|
| 297 |
+
// Suggest uploading photos
|
| 298 |
+
alert(result.message)
|
| 299 |
+
// Then call uploadImagesForPhotListing with photos
|
| 300 |
+
}
|
| 301 |
+
```
|
| 302 |
+
|
| 303 |
+
#### 3. **Video Upload Flow**
|
| 304 |
+
|
| 305 |
+
```typescript
|
| 306 |
+
POST /listings/analyze-video
|
| 307 |
+
Headers: Authorization: Bearer {token}
|
| 308 |
+
Body: FormData with video file
|
| 309 |
+
Response: Video URL + suggestions
|
| 310 |
+
```
|
| 311 |
+
|
| 312 |
+
---
|
| 313 |
+
|
| 314 |
+
## Three Listing Methods (Smart Differentiation)
|
| 315 |
+
|
| 316 |
+
The system intelligently handles THREE different listing creation methods:
|
| 317 |
+
|
| 318 |
+
### 1️⃣ Text-Based Listing (Existing - User provides details via text)
|
| 319 |
+
|
| 320 |
+
```
|
| 321 |
+
User says: "I have a 3-bed, 2-bath in Lagos for 500k per month.
|
| 322 |
+
It has WiFi, AC, and parking."
|
| 323 |
+
|
| 324 |
+
FLOW:
|
| 325 |
+
1. AI extracts fields from text (bedrooms, bathrooms, price, etc.)
|
| 326 |
+
2. User uploads photos to validate
|
| 327 |
+
3. Backend:
|
| 328 |
+
- Validates images are property-related
|
| 329 |
+
- Just checks they match (no re-extraction needed)
|
| 330 |
+
- Uploads to Cloudflare with smart naming
|
| 331 |
+
4. Shows draft UI with text-provided data + validated photos
|
| 332 |
+
5. User edits via text: "change price to 450k"
|
| 333 |
+
6. AI infers listing_type from price: "rent"
|
| 334 |
+
7. User publishes: "publish this listing"
|
| 335 |
+
|
| 336 |
+
METHOD CONTEXT: listing_method="text"
|
| 337 |
+
```
|
| 338 |
+
|
| 339 |
+
### 2️⃣ Image-Based Listing (NEW - User uploads photos only)
|
| 340 |
+
|
| 341 |
+
```
|
| 342 |
+
User clicks "List with Photos"
|
| 343 |
+
|
| 344 |
+
FLOW:
|
| 345 |
+
1. User uploads 1-5 photos (no text details provided)
|
| 346 |
+
2. Backend:
|
| 347 |
+
- Validates images are property-related
|
| 348 |
+
- EXTRACTS ALL DETAILS: bedrooms, bathrooms, amenities
|
| 349 |
+
- GENERATES TITLE (short, max 2 sentences)
|
| 350 |
+
- GENERATES DESCRIPTION (full description)
|
| 351 |
+
- Creates intelligent filenames (location_title_date.jpg)
|
| 352 |
+
- Uploads to Cloudflare
|
| 353 |
+
3. Shows draft UI with AI-extracted fields
|
| 354 |
+
4. User is prompted: "What's the location, address, and price?"
|
| 355 |
+
5. User provides: "Lagos, Victoria Island, 500,000 per month"
|
| 356 |
+
6. AIDA Auto-infers:
|
| 357 |
+
- Currency from location: Lagos → NGN (via CurrencyManager API)
|
| 358 |
+
- Listing_type from price_type: "per month" → "rent" ✓
|
| 359 |
+
7. User can edit via text: "add gym to amenities", "change title"
|
| 360 |
+
8. User publishes: "publish this listing"
|
| 361 |
+
|
| 362 |
+
METHOD CONTEXT: listing_method="image"
|
| 363 |
+
AI EXTRACTS: bedrooms, bathrooms, amenities, description, title
|
| 364 |
+
AUTO-INFERRED: currency (from location), listing_type (from price_type)
|
| 365 |
+
```
|
| 366 |
+
|
| 367 |
+
### 3️⃣ Video-Based Listing (NEW - User uploads video, optionally photos)
|
| 368 |
+
|
| 369 |
+
```
|
| 370 |
+
User clicks "List with Video"
|
| 371 |
+
|
| 372 |
+
FLOW:
|
| 373 |
+
1. User uploads video (walkthrough)
|
| 374 |
+
2. Backend:
|
| 375 |
+
- Uploads to Cloudinary
|
| 376 |
+
- Creates intelligent filename
|
| 377 |
+
3. System suggests: "Video uploaded! Upload 2-3 photos for better detection."
|
| 378 |
+
4. User uploads photos
|
| 379 |
+
5. Backend:
|
| 380 |
+
- Validates images are property-related
|
| 381 |
+
- EXTRACTS ALL DETAILS from photos
|
| 382 |
+
- GENERATES TITLE and DESCRIPTION
|
| 383 |
+
6. Shows draft UI with extracted fields + video URL
|
| 384 |
+
7. Same flow as image-based from step 5 onwards:
|
| 385 |
+
- User prompted for: location, address, price (with price_type)
|
| 386 |
+
- AIDA auto-infers: currency (from location), listing_type (from price_type)
|
| 387 |
+
|
| 388 |
+
METHOD CONTEXT: listing_method="video"
|
| 389 |
+
AI EXTRACTS: From photos (not video)
|
| 390 |
+
AUTO-INFERRED: currency (from location), listing_type (from price_type)
|
| 391 |
+
VIDEO STORAGE: Cloudinary
|
| 392 |
+
PHOTO STORAGE: Cloudflare
|
| 393 |
+
```
|
| 394 |
+
|
| 395 |
+
### Unified Draft UI Result
|
| 396 |
+
|
| 397 |
+
**All three methods produce the SAME final result:**
|
| 398 |
+
|
| 399 |
+
```json
|
| 400 |
+
{
|
| 401 |
+
"success": true,
|
| 402 |
+
"listing_method": "text|image|video",
|
| 403 |
+
"extracted_fields": {
|
| 404 |
+
"bedrooms": 3,
|
| 405 |
+
"bathrooms": 2,
|
| 406 |
+
"amenities": ["WiFi", "Parking", "AC"],
|
| 407 |
+
"description": "Beautiful apartment with modern amenities.",
|
| 408 |
+
"title": "3-Bed Modern Apartment. Great location!"
|
| 409 |
+
},
|
| 410 |
+
"confidence": { ... },
|
| 411 |
+
"image_urls": [ ... ],
|
| 412 |
+
"video_url": "..." // Only if video method
|
| 413 |
+
}
|
| 414 |
+
```
|
| 415 |
+
|
| 416 |
+
The **frontend shows the same UI** regardless of listing method - user sees:
|
| 417 |
+
- Property images
|
| 418 |
+
- Extracted details
|
| 419 |
+
- Ability to edit via text commands
|
| 420 |
+
- Publish button
|
| 421 |
+
|
| 422 |
+
---
|
| 423 |
+
|
| 424 |
+
## Data Flow Example
|
| 425 |
+
|
| 426 |
+
### Request
|
| 427 |
+
|
| 428 |
+
```bash
|
| 429 |
+
curl -X POST http://localhost:8000/listings/analyze-images \
|
| 430 |
+
-H "Authorization: Bearer {token}" \
|
| 431 |
+
-F "images=@bedroom.jpg" \
|
| 432 |
+
-F "images=@kitchen.jpg" \
|
| 433 |
+
-F "images=@bathroom.jpg"
|
| 434 |
+
```
|
| 435 |
+
|
| 436 |
+
### Response
|
| 437 |
+
|
| 438 |
+
```json
|
| 439 |
+
{
|
| 440 |
+
"success": true,
|
| 441 |
+
"images_processed": 3,
|
| 442 |
+
"images_validated": ["bedroom.jpg", "kitchen.jpg", "bathroom.jpg"],
|
| 443 |
+
"image_urls": [
|
| 444 |
+
"https://imagedelivery.net/lojiz/bedroom_hash/public",
|
| 445 |
+
"https://imagedelivery.net/lojiz/kitchen_hash/public",
|
| 446 |
+
"https://imagedelivery.net/lojiz/bathroom_hash/public"
|
| 447 |
+
],
|
| 448 |
+
"extracted_fields": {
|
| 449 |
+
"bedrooms": 3,
|
| 450 |
+
"bathrooms": 2,
|
| 451 |
+
"amenities": ["WiFi Router", "AC Unit", "Furniture", "Balcony"],
|
| 452 |
+
"description": "Beautiful 3-bedroom, 2-bathroom modern apartment with contemporary furnishings and excellent amenities."
|
| 453 |
+
},
|
| 454 |
+
"confidence": {
|
| 455 |
+
"bedrooms": 0.95,
|
| 456 |
+
"bathrooms": 0.88,
|
| 457 |
+
"amenities": 0.72,
|
| 458 |
+
"description": 0.91
|
| 459 |
+
},
|
| 460 |
+
"validation_errors": [],
|
| 461 |
+
"suggestions": [
|
| 462 |
+
"Verify bedroom and bathroom counts are accurate",
|
| 463 |
+
"You'll need to provide location, address, and price information"
|
| 464 |
+
]
|
| 465 |
+
}
|
| 466 |
+
```
|
| 467 |
+
|
| 468 |
+
---
|
| 469 |
+
|
| 470 |
+
## API Endpoints Summary
|
| 471 |
+
|
| 472 |
+
| Endpoint | Method | Purpose | Auth |
|
| 473 |
+
|----------|--------|---------|------|
|
| 474 |
+
| `/listings/analyze-images` | POST | Upload & analyze images | Required |
|
| 475 |
+
| `/listings/analyze-video` | POST | Upload & analyze video | Required |
|
| 476 |
+
| `/listings/validate-media` | POST | Quick file validation | Required |
|
| 477 |
+
|
| 478 |
+
---
|
| 479 |
+
|
| 480 |
+
## Important Notes
|
| 481 |
+
|
| 482 |
+
### Image Validation
|
| 483 |
+
|
| 484 |
+
- **Property validation happens BEFORE upload** - Non-property images are rejected, saving Cloudflare storage
|
| 485 |
+
- **Confidence threshold**: Default 0.6 (60%) - Can be adjusted via `PROPERTY_IMAGE_MIN_CONFIDENCE`
|
| 486 |
+
- **High-confidence fields** (>0.7): Auto-filled in listing form
|
| 487 |
+
- **Medium-confidence fields** (0.5-0.7): Shown as suggestions; user confirms
|
| 488 |
+
- **Low-confidence fields** (<0.5): User must provide manually
|
| 489 |
+
|
| 490 |
+
### Video Processing
|
| 491 |
+
|
| 492 |
+
- Videos uploaded to **Cloudinary** (not Cloudflare)
|
| 493 |
+
- Frame extraction available for future frame-by-frame analysis
|
| 494 |
+
- Users encouraged to upload photos alongside video for better accuracy
|
| 495 |
+
|
| 496 |
+
### Listing Type Inference
|
| 497 |
+
|
| 498 |
+
After user provides **price**, system infers listing_type:
|
| 499 |
+
|
| 500 |
+
```python
|
| 501 |
+
Price Input → Listing Type
|
| 502 |
+
- High monthly (e.g., 500,000/month) → "rent"
|
| 503 |
+
- Low nightly (e.g., 5,000/night) → "short-stay"
|
| 504 |
+
- Very high one-time (e.g., 50,000,000) → "sale"
|
| 505 |
+
- "Looking for roommate" context → "roommate"
|
| 506 |
+
```
|
| 507 |
+
|
| 508 |
+
---
|
| 509 |
+
|
| 510 |
+
## Testing
|
| 511 |
+
|
| 512 |
+
### Test 1: TEXT Method (User provided text details + uploading images)
|
| 513 |
+
|
| 514 |
+
```bash
|
| 515 |
+
# User already provided details via chat
|
| 516 |
+
# Now uploading images to validate
|
| 517 |
+
|
| 518 |
+
curl -X POST /listings/analyze-images \
|
| 519 |
+
-H "Authorization: Bearer {token}" \
|
| 520 |
+
-F "images=@bedroom.jpg" \
|
| 521 |
+
-F "images=@kitchen.jpg" \
|
| 522 |
+
-F "listing_method=text" \
|
| 523 |
+
-F "location=Lagos"
|
| 524 |
+
|
| 525 |
+
# Response:
|
| 526 |
+
# - Images validated as property-related ✓
|
| 527 |
+
# - Details preserved from text conversation
|
| 528 |
+
# - Returns same format with extracted fields + image URLs
|
| 529 |
+
```
|
| 530 |
+
|
| 531 |
+
### Test 2: IMAGE Method (User uploading photos only)
|
| 532 |
+
|
| 533 |
+
```bash
|
| 534 |
+
# User has no text details - AI extracts everything
|
| 535 |
+
|
| 536 |
+
curl -X POST /listings/analyze-images \
|
| 537 |
+
-H "Authorization: Bearer {token}" \
|
| 538 |
+
-F "images=@bedroom.jpg" \
|
| 539 |
+
-F "images=@kitchen.jpg" \
|
| 540 |
+
-F "images=@bathroom.jpg" \
|
| 541 |
+
-F "listing_method=image"
|
| 542 |
+
|
| 543 |
+
# Response:
|
| 544 |
+
# - bedrooms: 3 (extracted from images)
|
| 545 |
+
# - bathrooms: 2 (extracted from images)
|
| 546 |
+
# - title: "Modern 3-Bed Apartment. Great Location!" (AI-generated, SHORT)
|
| 547 |
+
# - description: "Beautiful apartment with..." (AI-generated, full)
|
| 548 |
+
# - amenities: ["WiFi", "AC", "Parking"] (extracted)
|
| 549 |
+
# - confidence: { bedrooms: 0.95, bathrooms: 0.88, ... }
|
| 550 |
+
```
|
| 551 |
+
|
| 552 |
+
### Test 3: VIDEO Method (User uploading video + photos)
|
| 553 |
+
|
| 554 |
+
```bash
|
| 555 |
+
# Step 1: Upload video
|
| 556 |
+
curl -X POST /listings/analyze-video \
|
| 557 |
+
-H "Authorization: Bearer {token}" \
|
| 558 |
+
-F "video=@walkthrough.mp4" \
|
| 559 |
+
-F "location=Lagos"
|
| 560 |
+
|
| 561 |
+
# Response: video_url, suggestions to upload photos
|
| 562 |
+
|
| 563 |
+
# Step 2: Upload photos for analysis
|
| 564 |
+
curl -X POST /listings/analyze-images \
|
| 565 |
+
-H "Authorization: Bearer {token}" \
|
| 566 |
+
-F "images=@photo1.jpg" \
|
| 567 |
+
-F "images=@photo2.jpg" \
|
| 568 |
+
-F "listing_method=video" \
|
| 569 |
+
-F "location=Lagos"
|
| 570 |
+
|
| 571 |
+
# Response: Same as IMAGE method + video_url in final listing
|
| 572 |
+
```
|
| 573 |
+
|
| 574 |
+
### Test 4: File Naming
|
| 575 |
+
|
| 576 |
+
```bash
|
| 577 |
+
# Upload images with location context
|
| 578 |
+
curl -X POST /listings/analyze-images \
|
| 579 |
+
-F "images=@IMG_1234.jpg" \
|
| 580 |
+
-F "images=@IMG_5678.jpg" \
|
| 581 |
+
-F "listing_method=image" \
|
| 582 |
+
-F "location=Lagos"
|
| 583 |
+
|
| 584 |
+
# Backend generates:
|
| 585 |
+
# - Lagos_Modern_Apartment_2025_01_31_0.jpg
|
| 586 |
+
# - Lagos_Modern_Apartment_2025_01_31_1.jpg
|
| 587 |
+
# (AI extracts title from image and uses it in filename)
|
| 588 |
+
|
| 589 |
+
# Cloudflare stores with these intelligent names
|
| 590 |
+
# If duplicate: Lagos_Modern_Apartment_2025_01_31_0_1.jpg (worker appends _1)
|
| 591 |
+
```
|
| 592 |
+
|
| 593 |
+
### Test 5: Short Title Validation
|
| 594 |
+
|
| 595 |
+
```bash
|
| 596 |
+
# Verify title is SHORT (max 2 sentences)
|
| 597 |
+
|
| 598 |
+
Response:
|
| 599 |
+
{
|
| 600 |
+
"extracted_fields": {
|
| 601 |
+
"title": "Modern 3-Bed Apartment. Great location!", ✓ SHORT
|
| 602 |
+
"description": "Beautiful 3-bedroom, 2-bathroom modern apartment..." ✓ FULL
|
| 603 |
+
}
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
# NOT acceptable:
|
| 607 |
+
{
|
| 608 |
+
"title": "This is a beautiful 3-bedroom, 2-bathroom modern apartment..." ❌ TOO LONG
|
| 609 |
+
}
|
| 610 |
+
```
|
| 611 |
+
|
| 612 |
+
---
|
| 613 |
+
|
| 614 |
+
## Error Handling
|
| 615 |
+
|
| 616 |
+
### Common Errors
|
| 617 |
+
|
| 618 |
+
| Error | Cause | Solution |
|
| 619 |
+
|-------|-------|----------|
|
| 620 |
+
| `Not a property photo` | Image rejected by vision AI | Upload actual property photos |
|
| 621 |
+
| `Image size exceeds 10MB` | File too large | Compress image or use smaller file |
|
| 622 |
+
| `Invalid image type` | Wrong file format | Use JPEG, PNG, or WebP |
|
| 623 |
+
| `Cloudinary upload failed` | Credentials not set | Check `.env` variables |
|
| 624 |
+
| `HF API timeout` | Vision model slow | Retry or use Cloudinary-hosted fallback |
|
| 625 |
+
|
| 626 |
+
---
|
| 627 |
+
|
| 628 |
+
## Smart File Naming & Storage
|
| 629 |
+
|
| 630 |
+
### Intelligent Filename Generation
|
| 631 |
+
|
| 632 |
+
**Backend generates meaningful filenames instead of using random names:**
|
| 633 |
+
|
| 634 |
+
```python
|
| 635 |
+
Pattern: {location}_{title}_{timestamp}_{index}.jpg
|
| 636 |
+
|
| 637 |
+
Examples:
|
| 638 |
+
- Lagos_Modern_Apartment_2025_01_31_1.jpg
|
| 639 |
+
- Victoria_Island_3_Bed_Luxury_2025_01_31_0.jpg
|
| 640 |
+
- Cotonou_Cozy_Studio_2025_01_31_0.jpg
|
| 641 |
+
```
|
| 642 |
+
|
| 643 |
+
**Algorithm:**
|
| 644 |
+
1. Extract location (if available)
|
| 645 |
+
2. Extract title (first 20 chars, AI-generated if image/video method)
|
| 646 |
+
3. Add timestamp (YYYY_MM_DD_HHMMSS)
|
| 647 |
+
4. Add index for multiple images (0, 1, 2...)
|
| 648 |
+
|
| 649 |
+
**Benefits:**
|
| 650 |
+
- Easy to identify property in storage
|
| 651 |
+
- Date shows when listed
|
| 652 |
+
- Cloudflare worker can detect duplicates
|
| 653 |
+
- Organized file structure
|
| 654 |
+
|
| 655 |
+
### Cloudflare Worker Deduplication
|
| 656 |
+
|
| 657 |
+
When image reaches Cloudflare:
|
| 658 |
+
```
|
| 659 |
+
1. Check if filename exists
|
| 660 |
+
2. If NEW → Store as-is
|
| 661 |
+
3. If DUPLICATE → Append counter
|
| 662 |
+
- first duplicate: {name}_1.jpg
|
| 663 |
+
- second: {name}_2.jpg
|
| 664 |
+
```
|
| 665 |
+
|
| 666 |
+
**Example:**
|
| 667 |
+
```
|
| 668 |
+
Scenario: Same user uploads "Lagos_Apartment.jpg" twice
|
| 669 |
+
1st upload → Lagos_Apartment.jpg
|
| 670 |
+
2nd upload → Lagos_Apartment_1.jpg (worker auto-appended)
|
| 671 |
+
```
|
| 672 |
+
|
| 673 |
+
---
|
| 674 |
+
|
| 675 |
+
## Title & Description Generation
|
| 676 |
+
|
| 677 |
+
### Title Requirements
|
| 678 |
+
|
| 679 |
+
**MUST BE SHORT:**
|
| 680 |
+
- ✅ "Modern 3-bed apartment. Great location!"
|
| 681 |
+
- ✅ "Spacious family home with garden."
|
| 682 |
+
- ❌ "This is a beautiful 3-bedroom, 2-bathroom modern apartment with contemporary furnishings, located in a prime area of the city with excellent amenities and facilities"
|
| 683 |
+
|
| 684 |
+
**Maximum:** 2 sentences (not full descriptions)
|
| 685 |
+
|
| 686 |
+
**Generated by Vision AI for image/video methods:**
|
| 687 |
+
```python
|
| 688 |
+
Example prompts:
|
| 689 |
+
"Generate a SHORT, catchy real estate listing title for this property (3bed, 2bath) in Lagos.
|
| 690 |
+
Maximum 2 sentences. Must be concise and appealing.
|
| 691 |
+
Example: 'Modern 2-bed apartment with balcony. Great location!'"
|
| 692 |
+
```
|
| 693 |
+
|
| 694 |
+
### Description Generation
|
| 695 |
+
|
| 696 |
+
**Full property description (2-3 sentences):**
|
| 697 |
+
- Generated from images/video
|
| 698 |
+
- Professional tone
|
| 699 |
+
- Highlights key features
|
| 700 |
+
- Stored in `extracted_fields.description`
|
| 701 |
+
|
| 702 |
+
**Example:**
|
| 703 |
+
```
|
| 704 |
+
"Beautiful 3-bedroom, 2-bathroom modern apartment featuring contemporary
|
| 705 |
+
furnishings, air conditioning, WiFi, and private balcony overlooking the
|
| 706 |
+
city. Located in a secure, gated community with excellent amenities."
|
| 707 |
+
```
|
| 708 |
+
|
| 709 |
+
---
|
| 710 |
+
|
| 711 |
+
## Performance Optimization
|
| 712 |
+
|
| 713 |
+
### Recommended for Production
|
| 714 |
+
|
| 715 |
+
1. **Implement caching**: Cache similar property images to reduce API calls
|
| 716 |
+
2. **Batch processing**: Process multiple images in parallel
|
| 717 |
+
3. **Frame extraction**: For videos, extract key frames instead of all frames
|
| 718 |
+
4. **Model optimization**: Consider smaller model variant for faster inference
|
| 719 |
+
5. **Async processing**: Long-running tasks (video analysis) should be async jobs
|
| 720 |
+
|
| 721 |
+
### Estimated Response Times
|
| 722 |
+
|
| 723 |
+
- Image validation: **2-3 seconds** (first image), **+1s per additional**
|
| 724 |
+
- Video upload: **5-10 seconds** depending on file size
|
| 725 |
+
- Vision analysis: **2-4 seconds** per image
|
| 726 |
+
|
| 727 |
+
---
|
| 728 |
+
|
| 729 |
+
## Success Metrics
|
| 730 |
+
|
| 731 |
+
Track these to measure feature adoption:
|
| 732 |
+
|
| 733 |
+
1. **Adoption Rate**: % of new listings created via image/video upload
|
| 734 |
+
2. **Time Saved**: Avg creation time (image-based vs text-based)
|
| 735 |
+
3. **Accuracy**: % of auto-detected fields accepted by users
|
| 736 |
+
4. **Field Coverage**: Which fields have highest accuracy
|
| 737 |
+
5. **Error Rate**: % of images rejected as non-property
|
| 738 |
+
|
| 739 |
+
---
|
| 740 |
+
|
| 741 |
+
## Future Enhancements
|
| 742 |
+
|
| 743 |
+
1. **Multi-frame video analysis**: Extract key frames from video, analyze each
|
| 744 |
+
2. **OCR for signs**: Extract property addresses from signs visible in photos
|
| 745 |
+
3. **Furniture detection**: Count furniture items, estimate age
|
| 746 |
+
4. **Damage detection**: Identify needed repairs
|
| 747 |
+
5. **Neighborhood analysis**: Analyze background (street view, buildings)
|
| 748 |
+
6. **Price estimation**: AI suggests price based on similar listings
|
| 749 |
+
7. **Virtual tour generation**: Automatically create walkthrough from photos
|
| 750 |
+
|
| 751 |
+
---
|
| 752 |
+
|
| 753 |
+
## Support & Troubleshooting
|
| 754 |
+
|
| 755 |
+
### Check Vision Service Status
|
| 756 |
+
|
| 757 |
+
```bash
|
| 758 |
+
GET /health
|
| 759 |
+
# Returns: vision_service: "healthy" | "unavailable"
|
| 760 |
+
```
|
| 761 |
+
|
| 762 |
+
### View Logs
|
| 763 |
+
|
| 764 |
+
```bash
|
| 765 |
+
# Backend logs for vision analysis
|
| 766 |
+
grep "Vision Service" logs/app.log
|
| 767 |
+
grep "Hugging Face API" logs/app.log
|
| 768 |
+
```
|
| 769 |
+
|
| 770 |
+
### Reset Cloudinary Cache
|
| 771 |
+
|
| 772 |
+
```bash
|
| 773 |
+
# Clear vision service cache (if implemented)
|
| 774 |
+
DELETE /admin/cache/vision
|
| 775 |
+
```
|
| 776 |
+
|
| 777 |
+
---
|
| 778 |
+
|
| 779 |
+
## Summary
|
| 780 |
+
|
| 781 |
+
✅ **Phase 1 Complete:**
|
| 782 |
+
- Vision service created (Hugging Face integration)
|
| 783 |
+
- Media upload endpoints ready
|
| 784 |
+
- Property validation implemented
|
| 785 |
+
- Listing collection integration done
|
| 786 |
+
- Image/video storage configured
|
| 787 |
+
|
| 788 |
+
**Next Steps:**
|
| 789 |
+
1. Update frontend to use `/listings/analyze-images` endpoint
|
| 790 |
+
2. Update frontend to use `/listings/analyze-video` endpoint
|
| 791 |
+
3. Add vision results to chat UI
|
| 792 |
+
4. Test end-to-end flow
|
| 793 |
+
5. Monitor accuracy metrics
|
| 794 |
+
6. Optimize based on user feedback
|
app/ai/agent/nodes/listing_collect.py
CHANGED
|
@@ -30,6 +30,82 @@ llm = ChatOpenAI(
|
|
| 30 |
temperature=0.7,
|
| 31 |
)
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
async def generate_contextual_question(state: AgentState, next_field: str = None) -> str:
|
| 34 |
"""Generate natural, contextual questions based on current conversation state"""
|
| 35 |
|
|
|
|
| 30 |
temperature=0.7,
|
| 31 |
)
|
| 32 |
|
| 33 |
+
async def initialize_from_vision_analysis(
|
| 34 |
+
state: AgentState,
|
| 35 |
+
vision_data: Dict
|
| 36 |
+
) -> AgentState:
|
| 37 |
+
"""
|
| 38 |
+
Initialize listing from AI vision analysis (images/video)
|
| 39 |
+
|
| 40 |
+
Populates state with auto-detected fields and sets up for user confirmation.
|
| 41 |
+
User will be prompted for required fields: location, address, price (with price_type).
|
| 42 |
+
|
| 43 |
+
Auto-inferred fields:
|
| 44 |
+
- Currency: Auto-detected from location via external API
|
| 45 |
+
- Listing type: Auto-inferred from price_type (per month → rent, once → sale, etc.)
|
| 46 |
+
|
| 47 |
+
Args:
|
| 48 |
+
state: Current agent state
|
| 49 |
+
vision_data: Dict with extracted fields from vision service
|
| 50 |
+
|
| 51 |
+
Returns:
|
| 52 |
+
Updated state ready for collection
|
| 53 |
+
"""
|
| 54 |
+
try:
|
| 55 |
+
# Extract vision analysis results
|
| 56 |
+
extracted_fields = vision_data.get("extracted_fields", {})
|
| 57 |
+
confidence = vision_data.get("confidence", {})
|
| 58 |
+
image_urls = vision_data.get("image_urls", [])
|
| 59 |
+
|
| 60 |
+
logger.info("🤖 Initializing listing from vision analysis",
|
| 61 |
+
bedrooms=extracted_fields.get("bedrooms"),
|
| 62 |
+
bathrooms=extracted_fields.get("bathrooms"),
|
| 63 |
+
amenities_count=len(extracted_fields.get("amenities", [])))
|
| 64 |
+
|
| 65 |
+
# Pre-fill detected fields with high confidence (>0.7)
|
| 66 |
+
high_confidence_threshold = 0.7
|
| 67 |
+
|
| 68 |
+
# Always add images (they were validated)
|
| 69 |
+
if image_urls:
|
| 70 |
+
state.update_listing_progress("images", image_urls)
|
| 71 |
+
logger.info(f"✅ Added {len(image_urls)} validated images")
|
| 72 |
+
|
| 73 |
+
# Bedrooms (high confidence)
|
| 74 |
+
if extracted_fields.get("bedrooms") is not None and confidence.get("bedrooms", 0) > high_confidence_threshold:
|
| 75 |
+
state.update_listing_progress("bedrooms", extracted_fields["bedrooms"])
|
| 76 |
+
logger.info(f"✅ Auto-filled bedrooms: {extracted_fields['bedrooms']}")
|
| 77 |
+
|
| 78 |
+
# Bathrooms (high confidence)
|
| 79 |
+
if extracted_fields.get("bathrooms") is not None and confidence.get("bathrooms", 0) > high_confidence_threshold:
|
| 80 |
+
state.update_listing_progress("bathrooms", extracted_fields["bathrooms"])
|
| 81 |
+
logger.info(f"✅ Auto-filled bathrooms: {extracted_fields['bathrooms']}")
|
| 82 |
+
|
| 83 |
+
# Amenities (even medium confidence is good for amenities)
|
| 84 |
+
if extracted_fields.get("amenities") and confidence.get("amenities", 0) > 0.5:
|
| 85 |
+
state.update_listing_progress("amenities", extracted_fields["amenities"])
|
| 86 |
+
logger.info(f"✅ Auto-filled amenities: {extracted_fields['amenities']}")
|
| 87 |
+
|
| 88 |
+
# Description (if high confidence)
|
| 89 |
+
if extracted_fields.get("description") and confidence.get("description", 0) > high_confidence_threshold:
|
| 90 |
+
state.update_listing_progress("description", extracted_fields["description"])
|
| 91 |
+
logger.info(f"✅ Auto-filled description")
|
| 92 |
+
|
| 93 |
+
# Store vision confidence scores in temp_data for reference
|
| 94 |
+
state.temp_data["vision_confidence"] = confidence
|
| 95 |
+
state.temp_data["from_vision_analysis"] = True
|
| 96 |
+
|
| 97 |
+
# Set user message to indicate vision analysis was done
|
| 98 |
+
state.last_user_message = "[Vision analysis completed - awaiting user confirmation]"
|
| 99 |
+
|
| 100 |
+
logger.info("✅ Vision analysis initialization complete")
|
| 101 |
+
return state
|
| 102 |
+
|
| 103 |
+
except Exception as e:
|
| 104 |
+
logger.error("Error initializing from vision analysis", exc_info=e)
|
| 105 |
+
state.set_error(f"Error initializing from vision: {str(e)}", should_retry=True)
|
| 106 |
+
return state
|
| 107 |
+
|
| 108 |
+
|
| 109 |
async def generate_contextual_question(state: AgentState, next_field: str = None) -> str:
|
| 110 |
"""Generate natural, contextual questions based on current conversation state"""
|
| 111 |
|
app/ai/services/vision_service.py
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# app/ai/services/vision_service.py
|
| 3 |
+
# Vision AI Service for Property Image Analysis
|
| 4 |
+
# Uses Hugging Face Inference API (Moondream2 model)
|
| 5 |
+
# ============================================================
|
| 6 |
+
|
| 7 |
+
import io
|
| 8 |
+
import base64
|
| 9 |
+
import logging
|
| 10 |
+
from typing import Dict, List, Optional, Tuple
|
| 11 |
+
from PIL import Image
|
| 12 |
+
import requests
|
| 13 |
+
from app.config import settings
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class VisionService:
|
| 19 |
+
"""Service for analyzing property images and videos using Hugging Face API"""
|
| 20 |
+
|
| 21 |
+
def __init__(self):
|
| 22 |
+
self.hf_token = settings.HF_TOKEN or settings.HUGGINGFACE_API_KEY
|
| 23 |
+
self.model_id = settings.HF_VISION_MODEL
|
| 24 |
+
self.api_url = f"https://api-inference.huggingface.co/models/{self.model_id}"
|
| 25 |
+
self.headers = {"Authorization": f"Bearer {self.hf_token}"}
|
| 26 |
+
self.property_confidence_threshold = settings.PROPERTY_IMAGE_MIN_CONFIDENCE
|
| 27 |
+
|
| 28 |
+
# ============================================================
|
| 29 |
+
# Core Image Validation & Analysis
|
| 30 |
+
# ============================================================
|
| 31 |
+
|
| 32 |
+
def validate_property_image(self, image_bytes: bytes) -> Tuple[bool, float, str]:
|
| 33 |
+
"""
|
| 34 |
+
Validate if image is property-related before uploading
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
image_bytes: Raw image bytes
|
| 38 |
+
|
| 39 |
+
Returns:
|
| 40 |
+
Tuple of (is_valid, confidence, message)
|
| 41 |
+
"""
|
| 42 |
+
try:
|
| 43 |
+
# Check if image is readable
|
| 44 |
+
image = Image.open(io.BytesIO(image_bytes))
|
| 45 |
+
image_rgb = image.convert("RGB")
|
| 46 |
+
|
| 47 |
+
# Query vision model to check if it's a property
|
| 48 |
+
payload = {
|
| 49 |
+
"inputs": image_rgb,
|
| 50 |
+
"question": (
|
| 51 |
+
"Is this image a photo of a real property (house, apartment, room, "
|
| 52 |
+
"office, land, or commercial building)? Answer only yes or no."
|
| 53 |
+
),
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
response = self._query_hf_api(payload)
|
| 57 |
+
|
| 58 |
+
if not response:
|
| 59 |
+
return False, 0.0, "Failed to process image"
|
| 60 |
+
|
| 61 |
+
answer = response.strip().lower()
|
| 62 |
+
is_property = "yes" in answer or "this is a property" in answer.lower()
|
| 63 |
+
|
| 64 |
+
# Assign confidence based on response clarity
|
| 65 |
+
confidence = 0.95 if is_property else 0.5
|
| 66 |
+
|
| 67 |
+
if is_property:
|
| 68 |
+
return (
|
| 69 |
+
True,
|
| 70 |
+
confidence,
|
| 71 |
+
"Property image validated successfully"
|
| 72 |
+
)
|
| 73 |
+
else:
|
| 74 |
+
return (
|
| 75 |
+
False,
|
| 76 |
+
confidence,
|
| 77 |
+
"This doesn't look like a property photo. Please upload images of "
|
| 78 |
+
"actual properties (houses, apartments, rooms, offices, or land)."
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
except Exception as e:
|
| 82 |
+
logger.error(f"Error validating property image: {str(e)}")
|
| 83 |
+
return False, 0.0, f"Error processing image: {str(e)}"
|
| 84 |
+
|
| 85 |
+
# ============================================================
|
| 86 |
+
# Property Field Extraction
|
| 87 |
+
# ============================================================
|
| 88 |
+
|
| 89 |
+
def extract_property_fields(self, image_bytes: bytes, location: str = None) -> Dict:
|
| 90 |
+
"""
|
| 91 |
+
Extract property listing fields from image
|
| 92 |
+
|
| 93 |
+
Args:
|
| 94 |
+
image_bytes: Raw image bytes
|
| 95 |
+
location: Optional location for context
|
| 96 |
+
|
| 97 |
+
Returns:
|
| 98 |
+
Dict with extracted fields and confidence scores
|
| 99 |
+
"""
|
| 100 |
+
try:
|
| 101 |
+
image = Image.open(io.BytesIO(image_bytes))
|
| 102 |
+
image_rgb = image.convert("RGB")
|
| 103 |
+
|
| 104 |
+
extracted = {
|
| 105 |
+
"bedrooms": None,
|
| 106 |
+
"bathrooms": None,
|
| 107 |
+
"amenities": [],
|
| 108 |
+
"description": "",
|
| 109 |
+
"title": "",
|
| 110 |
+
"confidence": {}
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
# Query 1: Count rooms
|
| 114 |
+
rooms_data = self._extract_room_count(image_rgb)
|
| 115 |
+
extracted.update(rooms_data)
|
| 116 |
+
extracted["confidence"].update({
|
| 117 |
+
"bedrooms": rooms_data.get("bedroom_confidence", 0.0),
|
| 118 |
+
"bathrooms": rooms_data.get("bathroom_confidence", 0.0)
|
| 119 |
+
})
|
| 120 |
+
|
| 121 |
+
# Query 2: Detect amenities
|
| 122 |
+
amenities_data = self._detect_amenities(image_rgb)
|
| 123 |
+
extracted["amenities"] = amenities_data.get("amenities", [])
|
| 124 |
+
extracted["confidence"]["amenities"] = amenities_data.get("confidence", 0.0)
|
| 125 |
+
|
| 126 |
+
# Query 3: Generate description
|
| 127 |
+
description_data = self._generate_description(image_rgb)
|
| 128 |
+
extracted["description"] = description_data.get("description", "")
|
| 129 |
+
extracted["confidence"]["description"] = description_data.get("confidence", 0.0)
|
| 130 |
+
|
| 131 |
+
# Query 4: Generate SHORT title (max 2 sentences)
|
| 132 |
+
title_data = self._generate_title(
|
| 133 |
+
image_rgb,
|
| 134 |
+
bedrooms=extracted.get("bedrooms"),
|
| 135 |
+
bathrooms=extracted.get("bathrooms"),
|
| 136 |
+
location=location
|
| 137 |
+
)
|
| 138 |
+
extracted["title"] = title_data.get("title", "")
|
| 139 |
+
extracted["confidence"]["title"] = title_data.get("confidence", 0.0)
|
| 140 |
+
|
| 141 |
+
return extracted
|
| 142 |
+
|
| 143 |
+
except Exception as e:
|
| 144 |
+
logger.error(f"Error extracting property fields: {str(e)}")
|
| 145 |
+
return {
|
| 146 |
+
"bedrooms": None,
|
| 147 |
+
"bathrooms": None,
|
| 148 |
+
"amenities": [],
|
| 149 |
+
"description": "",
|
| 150 |
+
"title": "",
|
| 151 |
+
"confidence": {},
|
| 152 |
+
"error": str(e)
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
# ============================================================
|
| 156 |
+
# Specific Field Extraction Methods
|
| 157 |
+
# ============================================================
|
| 158 |
+
|
| 159 |
+
def _extract_room_count(self, image: Image.Image) -> Dict:
|
| 160 |
+
"""Extract bedroom and bathroom count"""
|
| 161 |
+
try:
|
| 162 |
+
payload = {
|
| 163 |
+
"inputs": image,
|
| 164 |
+
"question": (
|
| 165 |
+
"Count the number of bedrooms and bathrooms visible in this property. "
|
| 166 |
+
"Be conservative and only count actual rooms. "
|
| 167 |
+
"Format your answer exactly like this: bedrooms: [number], bathrooms: [number]"
|
| 168 |
+
),
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
response = self._query_hf_api(payload)
|
| 172 |
+
|
| 173 |
+
bedrooms = None
|
| 174 |
+
bathrooms = None
|
| 175 |
+
bedroom_conf = 0.0
|
| 176 |
+
bathroom_conf = 0.0
|
| 177 |
+
|
| 178 |
+
if response:
|
| 179 |
+
# Parse response
|
| 180 |
+
response_lower = response.lower()
|
| 181 |
+
|
| 182 |
+
# Extract bedrooms
|
| 183 |
+
if "bedrooms:" in response_lower:
|
| 184 |
+
try:
|
| 185 |
+
bed_str = response_lower.split("bedrooms:")[1].split(",")[0].strip()
|
| 186 |
+
bedrooms = int(''.join(filter(str.isdigit, bed_str)))
|
| 187 |
+
bedroom_conf = 0.85
|
| 188 |
+
except:
|
| 189 |
+
bedroom_conf = 0.3
|
| 190 |
+
|
| 191 |
+
# Extract bathrooms
|
| 192 |
+
if "bathrooms:" in response_lower:
|
| 193 |
+
try:
|
| 194 |
+
bath_str = response_lower.split("bathrooms:")[1].strip()
|
| 195 |
+
bathrooms = int(''.join(filter(str.isdigit, bath_str)))
|
| 196 |
+
bathroom_conf = 0.85
|
| 197 |
+
except:
|
| 198 |
+
bathroom_conf = 0.3
|
| 199 |
+
|
| 200 |
+
return {
|
| 201 |
+
"bedrooms": bedrooms,
|
| 202 |
+
"bathrooms": bathrooms,
|
| 203 |
+
"bedroom_confidence": bedroom_conf,
|
| 204 |
+
"bathroom_confidence": bathroom_conf
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
except Exception as e:
|
| 208 |
+
logger.error(f"Error extracting room count: {str(e)}")
|
| 209 |
+
return {
|
| 210 |
+
"bedrooms": None,
|
| 211 |
+
"bathrooms": None,
|
| 212 |
+
"bedroom_confidence": 0.0,
|
| 213 |
+
"bathroom_confidence": 0.0
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
def _detect_amenities(self, image: Image.Image) -> Dict:
|
| 217 |
+
"""Detect amenities visible in property"""
|
| 218 |
+
try:
|
| 219 |
+
payload = {
|
| 220 |
+
"inputs": image,
|
| 221 |
+
"question": (
|
| 222 |
+
"List all amenities and features visible in this property image. "
|
| 223 |
+
"Include things like: parking, WiFi (if visible), pool, garden, "
|
| 224 |
+
"air conditioning unit, furniture, appliances, security features, "
|
| 225 |
+
"balcony, etc. If none are clearly visible, respond with 'none'."
|
| 226 |
+
),
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
response = self._query_hf_api(payload)
|
| 230 |
+
amenities = []
|
| 231 |
+
confidence = 0.6
|
| 232 |
+
|
| 233 |
+
if response and response.lower() != "none":
|
| 234 |
+
# Parse amenities from response
|
| 235 |
+
amenities_text = response.split(",")
|
| 236 |
+
amenities = [a.strip() for a in amenities_text if a.strip()]
|
| 237 |
+
confidence = 0.75 if amenities else 0.3
|
| 238 |
+
|
| 239 |
+
return {
|
| 240 |
+
"amenities": amenities,
|
| 241 |
+
"confidence": confidence
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
except Exception as e:
|
| 245 |
+
logger.error(f"Error detecting amenities: {str(e)}")
|
| 246 |
+
return {"amenities": [], "confidence": 0.0}
|
| 247 |
+
|
| 248 |
+
def _generate_description(self, image: Image.Image) -> Dict:
|
| 249 |
+
"""Generate property description from image"""
|
| 250 |
+
try:
|
| 251 |
+
payload = {
|
| 252 |
+
"inputs": image,
|
| 253 |
+
"question": (
|
| 254 |
+
"Write a brief, professional 2-3 sentence description of this property "
|
| 255 |
+
"suitable for a real estate listing. Focus on condition, style, key features."
|
| 256 |
+
),
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
response = self._query_hf_api(payload)
|
| 260 |
+
|
| 261 |
+
return {
|
| 262 |
+
"description": response or "",
|
| 263 |
+
"confidence": 0.8 if response else 0.0
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
except Exception as e:
|
| 267 |
+
logger.error(f"Error generating description: {str(e)}")
|
| 268 |
+
return {"description": "", "confidence": 0.0}
|
| 269 |
+
|
| 270 |
+
def _generate_title(self, image: Image.Image, bedrooms: int = None, bathrooms: int = None, location: str = None) -> Dict:
|
| 271 |
+
"""Generate SHORT property title (max 2 sentences)"""
|
| 272 |
+
try:
|
| 273 |
+
# Build context for title generation
|
| 274 |
+
context = ""
|
| 275 |
+
if bedrooms is not None or bathrooms is not None:
|
| 276 |
+
context = f"({bedrooms}bed, {bathrooms}bath)"
|
| 277 |
+
if location:
|
| 278 |
+
context += f" in {location}"
|
| 279 |
+
|
| 280 |
+
payload = {
|
| 281 |
+
"inputs": image,
|
| 282 |
+
"question": (
|
| 283 |
+
f"Generate a SHORT, catchy real estate listing title for this property {context}. "
|
| 284 |
+
"Maximum 2 sentences. Must be concise and appealing. "
|
| 285 |
+
"Example: 'Modern 2-bed apartment with balcony. Great location!'"
|
| 286 |
+
),
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
response = self._query_hf_api(payload)
|
| 290 |
+
|
| 291 |
+
# Ensure it's short enough
|
| 292 |
+
if response and len(response) > 100:
|
| 293 |
+
# If too long, truncate at first period
|
| 294 |
+
sentences = response.split('.')
|
| 295 |
+
response = sentences[0].strip() + '.' if sentences[0] else response[:100]
|
| 296 |
+
|
| 297 |
+
return {
|
| 298 |
+
"title": response or "",
|
| 299 |
+
"confidence": 0.85 if response else 0.0
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
except Exception as e:
|
| 303 |
+
logger.error(f"Error generating title: {str(e)}")
|
| 304 |
+
return {"title": "", "confidence": 0.0}
|
| 305 |
+
|
| 306 |
+
# ============================================================
|
| 307 |
+
# Hugging Face API Communication
|
| 308 |
+
# ============================================================
|
| 309 |
+
|
| 310 |
+
def _query_hf_api(self, payload: Dict) -> Optional[str]:
|
| 311 |
+
"""
|
| 312 |
+
Query Hugging Face Inference API
|
| 313 |
+
|
| 314 |
+
Args:
|
| 315 |
+
payload: Dict with "inputs" (PIL Image) and "question" (str)
|
| 316 |
+
|
| 317 |
+
Returns:
|
| 318 |
+
Response text or None
|
| 319 |
+
"""
|
| 320 |
+
try:
|
| 321 |
+
# Convert PIL Image to bytes for API
|
| 322 |
+
if isinstance(payload.get("inputs"), Image.Image):
|
| 323 |
+
image_bytes = io.BytesIO()
|
| 324 |
+
payload["inputs"].save(image_bytes, format="JPEG")
|
| 325 |
+
image_bytes.seek(0)
|
| 326 |
+
|
| 327 |
+
# Send image as multipart
|
| 328 |
+
files = {"file": ("image.jpg", image_bytes, "image/jpeg")}
|
| 329 |
+
data = {"question": payload.get("question", "")}
|
| 330 |
+
|
| 331 |
+
response = requests.post(
|
| 332 |
+
self.api_url,
|
| 333 |
+
headers=self.headers,
|
| 334 |
+
files=files,
|
| 335 |
+
data=data,
|
| 336 |
+
timeout=30
|
| 337 |
+
)
|
| 338 |
+
else:
|
| 339 |
+
response = requests.post(
|
| 340 |
+
self.api_url,
|
| 341 |
+
headers=self.headers,
|
| 342 |
+
json=payload,
|
| 343 |
+
timeout=30
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
if response.status_code == 200:
|
| 347 |
+
result = response.json()
|
| 348 |
+
|
| 349 |
+
# Handle different response formats
|
| 350 |
+
if isinstance(result, list) and len(result) > 0:
|
| 351 |
+
return result[0].get("generated_text", "")
|
| 352 |
+
elif isinstance(result, dict):
|
| 353 |
+
return result.get("generated_text", "") or result.get("answer", "")
|
| 354 |
+
else:
|
| 355 |
+
return str(result)
|
| 356 |
+
|
| 357 |
+
else:
|
| 358 |
+
logger.error(f"HF API error: {response.status_code} - {response.text}")
|
| 359 |
+
return None
|
| 360 |
+
|
| 361 |
+
except Exception as e:
|
| 362 |
+
logger.error(f"Error querying HF API: {str(e)}")
|
| 363 |
+
return None
|
| 364 |
+
|
| 365 |
+
# ============================================================
|
| 366 |
+
# Utility Methods
|
| 367 |
+
# ============================================================
|
| 368 |
+
|
| 369 |
+
def merge_multiple_image_results(self, results_list: List[Dict]) -> Dict:
|
| 370 |
+
"""
|
| 371 |
+
Merge results from multiple images into single listing data
|
| 372 |
+
|
| 373 |
+
Args:
|
| 374 |
+
results_list: List of extracted field dicts from different images
|
| 375 |
+
|
| 376 |
+
Returns:
|
| 377 |
+
Consolidated dict with most likely values
|
| 378 |
+
"""
|
| 379 |
+
if not results_list:
|
| 380 |
+
return {}
|
| 381 |
+
|
| 382 |
+
consolidated = {
|
| 383 |
+
"bedrooms": None,
|
| 384 |
+
"bathrooms": None,
|
| 385 |
+
"amenities": [],
|
| 386 |
+
"description": "",
|
| 387 |
+
"confidence": {}
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
# Bedrooms: take highest count mentioned
|
| 391 |
+
bedrooms_list = [r.get("bedrooms") for r in results_list if r.get("bedrooms")]
|
| 392 |
+
if bedrooms_list:
|
| 393 |
+
consolidated["bedrooms"] = max(bedrooms_list)
|
| 394 |
+
consolidated["confidence"]["bedrooms"] = sum(
|
| 395 |
+
[r.get("confidence", {}).get("bedrooms", 0)
|
| 396 |
+
for r in results_list]
|
| 397 |
+
) / len(results_list)
|
| 398 |
+
|
| 399 |
+
# Bathrooms: take highest count mentioned
|
| 400 |
+
bathrooms_list = [r.get("bathrooms") for r in results_list if r.get("bathrooms")]
|
| 401 |
+
if bathrooms_list:
|
| 402 |
+
consolidated["bathrooms"] = max(bathrooms_list)
|
| 403 |
+
consolidated["confidence"]["bathrooms"] = sum(
|
| 404 |
+
[r.get("confidence", {}).get("bathrooms", 0)
|
| 405 |
+
for r in results_list]
|
| 406 |
+
) / len(results_list)
|
| 407 |
+
|
| 408 |
+
# Amenities: deduplicate and combine
|
| 409 |
+
all_amenities = set()
|
| 410 |
+
for result in results_list:
|
| 411 |
+
all_amenities.update(result.get("amenities", []))
|
| 412 |
+
consolidated["amenities"] = list(all_amenities)
|
| 413 |
+
consolidated["confidence"]["amenities"] = sum(
|
| 414 |
+
[r.get("confidence", {}).get("amenities", 0)
|
| 415 |
+
for r in results_list]
|
| 416 |
+
) / len(results_list)
|
| 417 |
+
|
| 418 |
+
# Description: use longest one
|
| 419 |
+
descriptions = [r.get("description", "") for r in results_list if r.get("description")]
|
| 420 |
+
if descriptions:
|
| 421 |
+
consolidated["description"] = max(descriptions, key=len)
|
| 422 |
+
consolidated["confidence"]["description"] = 0.8
|
| 423 |
+
|
| 424 |
+
return consolidated
|
app/config.py
CHANGED
|
@@ -88,6 +88,13 @@ class Settings(BaseSettings):
|
|
| 88 |
# ------------------------------------------------------------------
|
| 89 |
CF_ACCOUNT_ID: str = os.getenv("CF_ACCOUNT_ID", "")
|
| 90 |
CF_API_TOKEN: str = os.getenv("CF_API_TOKEN", "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
# ------------------------------------------------------------------
|
| 93 |
# Cloudflare R2 Storage (Audio Files)
|
|
@@ -103,6 +110,13 @@ class Settings(BaseSettings):
|
|
| 103 |
# ------------------------------------------------------------------
|
| 104 |
HF_WHISPER_MODEL: str = os.getenv("HF_WHISPER_MODEL", "openai/whisper-large-v3")
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
# ------------------------------------------------------------------
|
| 107 |
# LLM / Tooling keys
|
| 108 |
# ------------------------------------------------------------------
|
|
|
|
| 88 |
# ------------------------------------------------------------------
|
| 89 |
CF_ACCOUNT_ID: str = os.getenv("CF_ACCOUNT_ID", "")
|
| 90 |
CF_API_TOKEN: str = os.getenv("CF_API_TOKEN", "")
|
| 91 |
+
|
| 92 |
+
# ------------------------------------------------------------------
|
| 93 |
+
# Cloudinary (Video Storage)
|
| 94 |
+
# ------------------------------------------------------------------
|
| 95 |
+
CLOUDINARY_CLOUD_NAME: str = os.getenv("CLOUDINARY_CLOUD_NAME", "")
|
| 96 |
+
CLOUDINARY_API_KEY: str = os.getenv("CLOUDINARY_API_KEY", "")
|
| 97 |
+
CLOUDINARY_API_SECRET: str = os.getenv("CLOUDINARY_API_SECRET", "")
|
| 98 |
|
| 99 |
# ------------------------------------------------------------------
|
| 100 |
# Cloudflare R2 Storage (Audio Files)
|
|
|
|
| 110 |
# ------------------------------------------------------------------
|
| 111 |
HF_WHISPER_MODEL: str = os.getenv("HF_WHISPER_MODEL", "openai/whisper-large-v3")
|
| 112 |
|
| 113 |
+
# ------------------------------------------------------------------
|
| 114 |
+
# Vision AI (Property Analysis)
|
| 115 |
+
# ------------------------------------------------------------------
|
| 116 |
+
HF_VISION_MODEL: str = os.getenv("HF_VISION_MODEL", "vikhyatk/moondream2")
|
| 117 |
+
HF_VISION_API_ENABLED: bool = os.getenv("HF_VISION_API_ENABLED", "true").lower() == "true"
|
| 118 |
+
PROPERTY_IMAGE_MIN_CONFIDENCE: float = float(os.getenv("PROPERTY_IMAGE_MIN_CONFIDENCE", "0.6"))
|
| 119 |
+
|
| 120 |
# ------------------------------------------------------------------
|
| 121 |
# LLM / Tooling keys
|
| 122 |
# ------------------------------------------------------------------
|
app/routes/media_upload.py
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# app/routes/media_upload.py
|
| 3 |
+
# Media Upload & Property Analysis Routes
|
| 4 |
+
# Handles image validation, video upload, and field extraction
|
| 5 |
+
# ============================================================
|
| 6 |
+
|
| 7 |
+
import io
|
| 8 |
+
import logging
|
| 9 |
+
from typing import List, Optional
|
| 10 |
+
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException, status
|
| 11 |
+
from fastapi.responses import JSONResponse
|
| 12 |
+
import cloudinary
|
| 13 |
+
import cloudinary.uploader
|
| 14 |
+
from app.config import settings
|
| 15 |
+
from app.ai.services.vision_service import VisionService
|
| 16 |
+
from app.middleware.auth import get_current_user
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
router = APIRouter(prefix="/listings", tags=["media"])
|
| 21 |
+
|
| 22 |
+
# Initialize Vision Service
|
| 23 |
+
vision_service = VisionService()
|
| 24 |
+
|
| 25 |
+
# Configure Cloudinary
|
| 26 |
+
if settings.CLOUDINARY_CLOUD_NAME:
|
| 27 |
+
cloudinary.config(
|
| 28 |
+
cloud_name=settings.CLOUDINARY_CLOUD_NAME,
|
| 29 |
+
api_key=settings.CLOUDINARY_API_KEY,
|
| 30 |
+
api_secret=settings.CLOUDINARY_API_SECRET,
|
| 31 |
+
secure=True
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ============================================================
|
| 36 |
+
# File Validation & Limits
|
| 37 |
+
# ============================================================
|
| 38 |
+
|
| 39 |
+
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
| 40 |
+
ALLOWED_VIDEO_TYPES = {"video/mp4", "video/quicktime", "video/x-msvideo"}
|
| 41 |
+
MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10MB
|
| 42 |
+
MAX_VIDEO_SIZE = 100 * 1024 * 1024 # 100MB
|
| 43 |
+
MAX_IMAGES_PER_UPLOAD = 10
|
| 44 |
+
MAX_VIDEO_DURATION = 300 # 5 minutes
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ============================================================
|
| 48 |
+
# Helper Functions
|
| 49 |
+
# ============================================================
|
| 50 |
+
|
| 51 |
+
async def validate_image_file(file: UploadFile) -> bytes:
|
| 52 |
+
"""Validate and read image file"""
|
| 53 |
+
if file.content_type not in ALLOWED_IMAGE_TYPES:
|
| 54 |
+
raise HTTPException(
|
| 55 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 56 |
+
detail=f"Invalid image type. Allowed: {', '.join(ALLOWED_IMAGE_TYPES)}"
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
contents = await file.read()
|
| 60 |
+
if len(contents) > MAX_IMAGE_SIZE:
|
| 61 |
+
raise HTTPException(
|
| 62 |
+
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
| 63 |
+
detail=f"Image size exceeds {MAX_IMAGE_SIZE / 1024 / 1024}MB limit"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
return contents
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
async def validate_video_file(file: UploadFile) -> bytes:
|
| 70 |
+
"""Validate and read video file"""
|
| 71 |
+
if file.content_type not in ALLOWED_VIDEO_TYPES:
|
| 72 |
+
raise HTTPException(
|
| 73 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 74 |
+
detail=f"Invalid video type. Allowed: {', '.join(ALLOWED_VIDEO_TYPES)}"
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
contents = await file.read()
|
| 78 |
+
if len(contents) > MAX_VIDEO_SIZE:
|
| 79 |
+
raise HTTPException(
|
| 80 |
+
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
| 81 |
+
detail=f"Video size exceeds {MAX_VIDEO_SIZE / 1024 / 1024}MB limit"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
return contents
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def generate_intelligent_filename(
|
| 88 |
+
original_filename: str,
|
| 89 |
+
location: Optional[str] = None,
|
| 90 |
+
title: Optional[str] = None,
|
| 91 |
+
index: int = 0
|
| 92 |
+
) -> str:
|
| 93 |
+
"""
|
| 94 |
+
Generate intelligent filename for uploaded image
|
| 95 |
+
|
| 96 |
+
Pattern: {location}_{title}_{date}_{index}.jpg
|
| 97 |
+
Example: Lagos_Modern_Apartment_2025_01_31_1.jpg
|
| 98 |
+
|
| 99 |
+
The Cloudflare worker will handle duplicates by appending numbers
|
| 100 |
+
"""
|
| 101 |
+
from datetime import datetime
|
| 102 |
+
|
| 103 |
+
# Get original extension
|
| 104 |
+
_, ext = original_filename.rsplit('.', 1) if '.' in original_filename else (original_filename, 'jpg')
|
| 105 |
+
ext = ext.lower()
|
| 106 |
+
if ext not in ['jpg', 'jpeg', 'png', 'webp']:
|
| 107 |
+
ext = 'jpg'
|
| 108 |
+
|
| 109 |
+
# Build filename components
|
| 110 |
+
parts = []
|
| 111 |
+
|
| 112 |
+
# Add location if available
|
| 113 |
+
if location:
|
| 114 |
+
clean_location = location.replace(' ', '_').replace(',', '').lower()[:20]
|
| 115 |
+
parts.append(clean_location)
|
| 116 |
+
|
| 117 |
+
# Add title if available (first 20 chars)
|
| 118 |
+
if title:
|
| 119 |
+
clean_title = title.replace(' ', '_').replace(',', '').lower()[:20]
|
| 120 |
+
parts.append(clean_title)
|
| 121 |
+
|
| 122 |
+
# Add timestamp
|
| 123 |
+
timestamp = datetime.utcnow().strftime("%Y_%m_%d_%H%M%S")
|
| 124 |
+
parts.append(timestamp)
|
| 125 |
+
|
| 126 |
+
# Add index if multiple images
|
| 127 |
+
if index > 0:
|
| 128 |
+
parts.append(str(index))
|
| 129 |
+
|
| 130 |
+
filename = "_".join(parts)
|
| 131 |
+
return f"{filename}.{ext}"
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
async def upload_to_cloudflare(file_bytes: bytes, filename: str, meaningful_name: str = None) -> str:
|
| 135 |
+
"""
|
| 136 |
+
Upload image to Cloudflare
|
| 137 |
+
|
| 138 |
+
Args:
|
| 139 |
+
file_bytes: Image bytes
|
| 140 |
+
filename: Original filename
|
| 141 |
+
meaningful_name: AI-generated meaningful filename (optional)
|
| 142 |
+
|
| 143 |
+
The Cloudflare worker will:
|
| 144 |
+
1. Check if filename exists
|
| 145 |
+
2. If duplicate, append _1, _2, etc.
|
| 146 |
+
3. Return final URL with deduplicated name
|
| 147 |
+
"""
|
| 148 |
+
try:
|
| 149 |
+
# Use meaningful name if provided, otherwise original filename
|
| 150 |
+
final_filename = meaningful_name or filename
|
| 151 |
+
|
| 152 |
+
# This should use your existing Cloudflare upload utility
|
| 153 |
+
# Import from wherever you have it configured
|
| 154 |
+
# For example: from app.utils.cloudflare import upload_image
|
| 155 |
+
# url = await upload_image(file_bytes, final_filename)
|
| 156 |
+
# return url
|
| 157 |
+
|
| 158 |
+
# Placeholder - update with actual implementation
|
| 159 |
+
logger.warning(f"Cloudflare upload not fully implemented - using placeholder")
|
| 160 |
+
logger.info(f"Would upload to Cloudflare with filename: {final_filename}")
|
| 161 |
+
return f"https://imagedelivery.net/lojiz/{final_filename}/public"
|
| 162 |
+
|
| 163 |
+
except Exception as e:
|
| 164 |
+
logger.error(f"Error uploading to Cloudflare: {str(e)}")
|
| 165 |
+
raise HTTPException(
|
| 166 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 167 |
+
detail="Failed to upload image to cloud storage"
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
async def upload_to_cloudinary(file_bytes: bytes, filename: str, resource_type: str = "video") -> str:
|
| 172 |
+
"""Upload video to Cloudinary"""
|
| 173 |
+
try:
|
| 174 |
+
file_obj = io.BytesIO(file_bytes)
|
| 175 |
+
|
| 176 |
+
result = cloudinary.uploader.upload(
|
| 177 |
+
file_obj,
|
| 178 |
+
resource_type=resource_type,
|
| 179 |
+
folder="lojiz/property-videos",
|
| 180 |
+
public_id=filename.split(".")[0],
|
| 181 |
+
overwrite=True,
|
| 182 |
+
quality="auto",
|
| 183 |
+
fetch_format="auto"
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
return result.get("secure_url", "")
|
| 187 |
+
|
| 188 |
+
except Exception as e:
|
| 189 |
+
logger.error(f"Error uploading to Cloudinary: {str(e)}")
|
| 190 |
+
raise HTTPException(
|
| 191 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 192 |
+
detail="Failed to upload video to Cloudinary"
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
# ============================================================
|
| 197 |
+
# API Endpoints
|
| 198 |
+
# ============================================================
|
| 199 |
+
|
| 200 |
+
@router.post("/analyze-images")
|
| 201 |
+
async def analyze_property_images(
|
| 202 |
+
images: List[UploadFile] = File(...),
|
| 203 |
+
listing_method: str = "image", # "text", "image", or "video"
|
| 204 |
+
location: Optional[str] = None, # Optional context from text method
|
| 205 |
+
current_user = Depends(get_current_user)
|
| 206 |
+
):
|
| 207 |
+
"""
|
| 208 |
+
Analyze property images and extract listing fields
|
| 209 |
+
|
| 210 |
+
Supports three listing methods:
|
| 211 |
+
- "text": User provided details via text + uploading images to validate
|
| 212 |
+
- "image": User uploading images only (extract all details from images)
|
| 213 |
+
- "video": User uploading images alongside video
|
| 214 |
+
|
| 215 |
+
Args:
|
| 216 |
+
images: List of image files
|
| 217 |
+
listing_method: How user is listing (text, image, video)
|
| 218 |
+
location: Optional location context (if from text method)
|
| 219 |
+
current_user: Authenticated user
|
| 220 |
+
|
| 221 |
+
Flow:
|
| 222 |
+
1. Validate image is property-related (no upload yet)
|
| 223 |
+
2. Extract fields from image
|
| 224 |
+
3. Upload to Cloudflare if valid
|
| 225 |
+
4. Return extracted data + image URLs
|
| 226 |
+
|
| 227 |
+
Returns:
|
| 228 |
+
Same draft format for all methods - UI shows unified result
|
| 229 |
+
"""
|
| 230 |
+
if not images or len(images) > MAX_IMAGES_PER_UPLOAD:
|
| 231 |
+
raise HTTPException(
|
| 232 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 233 |
+
detail=f"Upload 1-{MAX_IMAGES_PER_UPLOAD} images"
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
# Validate listing_method
|
| 237 |
+
if listing_method not in ["text", "image", "video"]:
|
| 238 |
+
listing_method = "image"
|
| 239 |
+
|
| 240 |
+
logger.info(f"📸 Processing images with method: {listing_method}", location=location)
|
| 241 |
+
|
| 242 |
+
validated_images = []
|
| 243 |
+
extracted_results = []
|
| 244 |
+
image_urls = []
|
| 245 |
+
validation_errors = []
|
| 246 |
+
|
| 247 |
+
for idx, image_file in enumerate(images):
|
| 248 |
+
try:
|
| 249 |
+
# Step 1: Read and validate file format
|
| 250 |
+
image_bytes = await validate_image_file(image_file)
|
| 251 |
+
|
| 252 |
+
# Step 2: Validate it's a property image (BEFORE uploading)
|
| 253 |
+
is_valid, confidence, message = vision_service.validate_property_image(image_bytes)
|
| 254 |
+
|
| 255 |
+
if not is_valid:
|
| 256 |
+
validation_errors.append({
|
| 257 |
+
"image": image_file.filename,
|
| 258 |
+
"error": message,
|
| 259 |
+
"confidence": confidence
|
| 260 |
+
})
|
| 261 |
+
continue
|
| 262 |
+
|
| 263 |
+
# Step 3: Extract property fields (with location context if provided)
|
| 264 |
+
extracted = vision_service.extract_property_fields(image_bytes, location=location)
|
| 265 |
+
|
| 266 |
+
# Step 4: Generate intelligent filename for upload
|
| 267 |
+
meaningful_filename = generate_intelligent_filename(
|
| 268 |
+
original_filename=image_file.filename,
|
| 269 |
+
location=location,
|
| 270 |
+
title=extracted.get("title"),
|
| 271 |
+
index=idx
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
# Step 5: Upload to Cloudflare (only if validated)
|
| 275 |
+
image_url = await upload_to_cloudflare(
|
| 276 |
+
image_bytes,
|
| 277 |
+
image_file.filename,
|
| 278 |
+
meaningful_name=meaningful_filename
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
validated_images.append(image_file.filename)
|
| 282 |
+
extracted_results.append(extracted)
|
| 283 |
+
image_urls.append(image_url)
|
| 284 |
+
|
| 285 |
+
logger.info(f"✅ Successfully processed image: {image_file.filename} → {meaningful_filename}")
|
| 286 |
+
|
| 287 |
+
except HTTPException:
|
| 288 |
+
raise
|
| 289 |
+
except Exception as e:
|
| 290 |
+
logger.error(f"Error processing image {image_file.filename}: {str(e)}")
|
| 291 |
+
validation_errors.append({
|
| 292 |
+
"image": image_file.filename,
|
| 293 |
+
"error": f"Processing error: {str(e)}"
|
| 294 |
+
})
|
| 295 |
+
|
| 296 |
+
# If no valid images, return error
|
| 297 |
+
if not validated_images:
|
| 298 |
+
raise HTTPException(
|
| 299 |
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
| 300 |
+
detail={
|
| 301 |
+
"message": "No valid property images found",
|
| 302 |
+
"errors": validation_errors,
|
| 303 |
+
"suggestion": "Make sure images show actual properties (houses, apartments, rooms, offices, or land)"
|
| 304 |
+
}
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
# Merge results from multiple images
|
| 308 |
+
consolidated_fields = vision_service.merge_multiple_image_results(extracted_results)
|
| 309 |
+
|
| 310 |
+
# ============================================================
|
| 311 |
+
# NOTE: AIDA will handle next steps:
|
| 312 |
+
# ============================================================
|
| 313 |
+
# 1. For IMAGE/VIDEO methods: AIDA will ask user for:
|
| 314 |
+
# - Location (required)
|
| 315 |
+
# - Address (required)
|
| 316 |
+
# - Price + price_type (e.g., "500,000 per month") (required)
|
| 317 |
+
#
|
| 318 |
+
# 2. After user provides location:
|
| 319 |
+
# → AIDA calls infer_currency_from_location(location)
|
| 320 |
+
# → Auto-detects currency via external API (CurrencyManager)
|
| 321 |
+
# → Example: Lagos → NGN, London → GBP
|
| 322 |
+
#
|
| 323 |
+
# 3. After user provides price with price_type:
|
| 324 |
+
# → AIDA auto-infers listing_type from price_type
|
| 325 |
+
# → Example: "per month" → "rent", "once" → "sale"
|
| 326 |
+
#
|
| 327 |
+
# 4. Both currency and listing_type are auto-populated
|
| 328 |
+
# → No need to ask user for these
|
| 329 |
+
|
| 330 |
+
# Generate method-specific suggestions
|
| 331 |
+
if listing_method == "text":
|
| 332 |
+
# User provided text details, validate with images
|
| 333 |
+
suggestions = [
|
| 334 |
+
"Images validated successfully ✓",
|
| 335 |
+
"Your extracted details from text are saved",
|
| 336 |
+
"Add more photos if you want to showcase more features"
|
| 337 |
+
]
|
| 338 |
+
elif listing_method == "image":
|
| 339 |
+
# User uploading images only - we extracted all details
|
| 340 |
+
suggestions = [
|
| 341 |
+
"All property details extracted from images",
|
| 342 |
+
"Verify bedroom and bathroom counts",
|
| 343 |
+
"Add more photos for better visibility" if len(validated_images) < 3 else "Great selection of photos!"
|
| 344 |
+
]
|
| 345 |
+
else: # video
|
| 346 |
+
suggestions = [
|
| 347 |
+
"Video uploaded successfully",
|
| 348 |
+
"Photos analyzed for property details",
|
| 349 |
+
"Video will be shown alongside static photos"
|
| 350 |
+
]
|
| 351 |
+
|
| 352 |
+
return {
|
| 353 |
+
"success": True,
|
| 354 |
+
"listing_method": listing_method,
|
| 355 |
+
"images_processed": len(validated_images),
|
| 356 |
+
"images_validated": validated_images,
|
| 357 |
+
"image_urls": image_urls,
|
| 358 |
+
"extracted_fields": {
|
| 359 |
+
"bedrooms": consolidated_fields.get("bedrooms"),
|
| 360 |
+
"bathrooms": consolidated_fields.get("bathrooms"),
|
| 361 |
+
"amenities": consolidated_fields.get("amenities", []),
|
| 362 |
+
"description": consolidated_fields.get("description", ""),
|
| 363 |
+
"title": consolidated_fields.get("title", ""), # NEW: AI-generated SHORT title
|
| 364 |
+
},
|
| 365 |
+
"confidence": consolidated_fields.get("confidence", {}),
|
| 366 |
+
"validation_errors": validation_errors,
|
| 367 |
+
"suggestions": suggestions
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
@router.post("/analyze-video")
|
| 372 |
+
async def analyze_property_video(
|
| 373 |
+
video: UploadFile = File(...),
|
| 374 |
+
location: Optional[str] = None,
|
| 375 |
+
current_user = Depends(get_current_user)
|
| 376 |
+
):
|
| 377 |
+
"""
|
| 378 |
+
Analyze property video and extract listing fields
|
| 379 |
+
|
| 380 |
+
- Uploads video to Cloudinary
|
| 381 |
+
- Extracts key frames and analyzes them (if available)
|
| 382 |
+
- Returns extracted fields from video content
|
| 383 |
+
|
| 384 |
+
Note: Video is uploaded to Cloudinary for playback
|
| 385 |
+
Photo analysis is more effective - recommend uploading photos alongside video
|
| 386 |
+
"""
|
| 387 |
+
try:
|
| 388 |
+
# Step 1: Validate video file
|
| 389 |
+
video_bytes = await validate_video_file(video)
|
| 390 |
+
|
| 391 |
+
# Generate intelligent video filename
|
| 392 |
+
meaningful_filename = generate_intelligent_filename(
|
| 393 |
+
original_filename=video.filename,
|
| 394 |
+
location=location,
|
| 395 |
+
title="property_video",
|
| 396 |
+
index=0
|
| 397 |
+
).replace('.jpg', '.mp4') # Replace extension
|
| 398 |
+
|
| 399 |
+
# Step 2: Upload to Cloudinary
|
| 400 |
+
video_url = await upload_to_cloudinary(video_bytes, meaningful_filename, resource_type="video")
|
| 401 |
+
|
| 402 |
+
logger.info(f"✅ Video uploaded to Cloudinary: {video_url}")
|
| 403 |
+
|
| 404 |
+
# Step 3: Video analysis limited - need photos for accurate extraction
|
| 405 |
+
# In future, can implement frame extraction and analysis
|
| 406 |
+
extracted = {
|
| 407 |
+
"bedrooms": None,
|
| 408 |
+
"bathrooms": None,
|
| 409 |
+
"amenities": [],
|
| 410 |
+
"description": "Showcasing property via video walkthrough",
|
| 411 |
+
"title": "Property Video Tour",
|
| 412 |
+
"confidence": {
|
| 413 |
+
"bedrooms": 0.0,
|
| 414 |
+
"bathrooms": 0.0,
|
| 415 |
+
"amenities": 0.0,
|
| 416 |
+
"description": 0.4,
|
| 417 |
+
"title": 0.5
|
| 418 |
+
}
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
return {
|
| 422 |
+
"success": True,
|
| 423 |
+
"listing_method": "video",
|
| 424 |
+
"video_url": video_url,
|
| 425 |
+
"message": "Video uploaded to Cloudinary successfully! For better property detection, please also upload photos.",
|
| 426 |
+
"extracted_fields": {
|
| 427 |
+
"bedrooms": extracted.get("bedrooms"),
|
| 428 |
+
"bathrooms": extracted.get("bathrooms"),
|
| 429 |
+
"amenities": extracted.get("amenities", []),
|
| 430 |
+
"description": extracted.get("description", ""),
|
| 431 |
+
"title": extracted.get("title", "")
|
| 432 |
+
},
|
| 433 |
+
"confidence": extracted.get("confidence", {}),
|
| 434 |
+
"suggestions": [
|
| 435 |
+
"📸 Upload 2-3 property photos for AI to analyze",
|
| 436 |
+
"Photos help detect bedrooms, bathrooms, and amenities",
|
| 437 |
+
"Video will be shown as supplementary content"
|
| 438 |
+
]
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
except HTTPException:
|
| 442 |
+
raise
|
| 443 |
+
except Exception as e:
|
| 444 |
+
logger.error(f"Error processing video: {str(e)}")
|
| 445 |
+
raise HTTPException(
|
| 446 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 447 |
+
detail=f"Failed to process video: {str(e)}"
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
@router.post("/validate-media")
|
| 452 |
+
async def validate_media(
|
| 453 |
+
files: List[UploadFile] = File(...),
|
| 454 |
+
current_user = Depends(get_current_user)
|
| 455 |
+
):
|
| 456 |
+
"""
|
| 457 |
+
Quick validation endpoint to check if files are property-related
|
| 458 |
+
without uploading them
|
| 459 |
+
|
| 460 |
+
Useful for frontend to validate before sending full upload request
|
| 461 |
+
"""
|
| 462 |
+
results = []
|
| 463 |
+
|
| 464 |
+
for file in files:
|
| 465 |
+
try:
|
| 466 |
+
if file.content_type and file.content_type.startswith("image/"):
|
| 467 |
+
# Validate image
|
| 468 |
+
image_bytes = await validate_image_file(file)
|
| 469 |
+
is_valid, confidence, message = vision_service.validate_property_image(image_bytes)
|
| 470 |
+
|
| 471 |
+
results.append({
|
| 472 |
+
"filename": file.filename,
|
| 473 |
+
"type": "image",
|
| 474 |
+
"valid": is_valid,
|
| 475 |
+
"confidence": confidence,
|
| 476 |
+
"message": message
|
| 477 |
+
})
|
| 478 |
+
|
| 479 |
+
elif file.content_type and file.content_type.startswith("video/"):
|
| 480 |
+
# Video validation
|
| 481 |
+
video_bytes = await validate_video_file(file)
|
| 482 |
+
results.append({
|
| 483 |
+
"filename": file.filename,
|
| 484 |
+
"type": "video",
|
| 485 |
+
"valid": True,
|
| 486 |
+
"confidence": 1.0,
|
| 487 |
+
"message": "Video format accepted"
|
| 488 |
+
})
|
| 489 |
+
|
| 490 |
+
except HTTPException as e:
|
| 491 |
+
results.append({
|
| 492 |
+
"filename": file.filename,
|
| 493 |
+
"valid": False,
|
| 494 |
+
"confidence": 0.0,
|
| 495 |
+
"message": e.detail
|
| 496 |
+
})
|
| 497 |
+
|
| 498 |
+
valid_count = sum(1 for r in results if r["valid"])
|
| 499 |
+
invalid_count = len(results) - valid_count
|
| 500 |
+
|
| 501 |
+
return {
|
| 502 |
+
"total_files": len(results),
|
| 503 |
+
"valid_files": valid_count,
|
| 504 |
+
"invalid_files": invalid_count,
|
| 505 |
+
"files": results,
|
| 506 |
+
"ready_to_upload": invalid_count == 0
|
| 507 |
+
}
|
main.py
CHANGED
|
@@ -316,6 +316,7 @@ except Exception as e:
|
|
| 316 |
# LISTING ROUTERS
|
| 317 |
# ============================================================
|
| 318 |
from app.routes.listing import router as listing_router
|
|
|
|
| 319 |
from app.routes.user_public import router as user_public_router
|
| 320 |
from app.routes.websocket_listings import router as ws_router
|
| 321 |
from app.routes.websocket_chat import router as ws_chat_router
|
|
@@ -325,6 +326,7 @@ from app.routes.conversations import router as conversations_router
|
|
| 325 |
from app.routes.wishlist import router as wishlist_router
|
| 326 |
|
| 327 |
app.include_router(listing_router, prefix="/api/listings", tags=["Listings"])
|
|
|
|
| 328 |
app.include_router(user_public_router, prefix="/api/users", tags=["Users"])
|
| 329 |
app.include_router(ws_router, tags=["WebSocket Listings"])
|
| 330 |
app.include_router(ws_chat_router, tags=["WebSocket Chat"])
|
|
|
|
| 316 |
# LISTING ROUTERS
|
| 317 |
# ============================================================
|
| 318 |
from app.routes.listing import router as listing_router
|
| 319 |
+
from app.routes.media_upload import router as media_router
|
| 320 |
from app.routes.user_public import router as user_public_router
|
| 321 |
from app.routes.websocket_listings import router as ws_router
|
| 322 |
from app.routes.websocket_chat import router as ws_chat_router
|
|
|
|
| 326 |
from app.routes.wishlist import router as wishlist_router
|
| 327 |
|
| 328 |
app.include_router(listing_router, prefix="/api/listings", tags=["Listings"])
|
| 329 |
+
app.include_router(media_router, tags=["Media Upload & Analysis"])
|
| 330 |
app.include_router(user_public_router, prefix="/api/users", tags=["Users"])
|
| 331 |
app.include_router(ws_router, tags=["WebSocket Listings"])
|
| 332 |
app.include_router(ws_chat_router, tags=["WebSocket Chat"])
|
requirements.txt
CHANGED
|
@@ -93,6 +93,10 @@ httpx>=0.25.0
|
|
| 93 |
edge-tts>=6.1.9 # Microsoft Edge TTS (free)
|
| 94 |
boto3>=1.34.0 # AWS S3 SDK for Cloudflare R2
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
# ============================================================
|
| 97 |
# INSTALLATION:
|
| 98 |
# pip install -r requirements.txt
|
|
|
|
| 93 |
edge-tts>=6.1.9 # Microsoft Edge TTS (free)
|
| 94 |
boto3>=1.34.0 # AWS S3 SDK for Cloudflare R2
|
| 95 |
|
| 96 |
+
# --- Video Storage & Media Processing ---
|
| 97 |
+
cloudinary>=1.40.0 # Cloudinary video upload
|
| 98 |
+
ffmpeg-python>=0.2.1 # Video frame extraction
|
| 99 |
+
|
| 100 |
# ============================================================
|
| 101 |
# INSTALLATION:
|
| 102 |
# pip install -r requirements.txt
|