fix-tests-cleanup

This commit is contained in:
Tour
2025-12-08 05:37:29 +01:00
parent 7600cebcbb
commit 62cda5c0cb
22 changed files with 816 additions and 1386 deletions

View File

@@ -1,113 +0,0 @@
# Database Schema Fix Instructions
## Problem
The server database was created with `BIGINT` primary keys for `auction_id` and `lot_id`, but the scraper uses TEXT IDs like "A7-40063-2". This causes PRIMARY KEY constraint failures.
## Root Cause
- Local DB: `auction_id TEXT PRIMARY KEY`, `lot_id TEXT PRIMARY KEY`
- Server DB (old): `auction_id BIGINT PRIMARY KEY`, `lot_id BIGINT PRIMARY KEY`
- Scraper data: Uses TEXT IDs like "A7-40063-2", "A1-34732-49"
This mismatch prevents the scraper from inserting data, resulting in zero bids showing in the UI.
## Solution
### Step 1: Backup Server Database
```bash
ssh tour@192.168.1.149
cd /mnt/okcomputer/output
cp cache.db cache.db.backup.$(date +%Y%m%d_%H%M%S)
```
### Step 2: Upload Fix Script
From your local machine:
```bash
scp C:\vibe\auctiora\fix-schema.sql tour@192.168.1.149:/tmp/
```
### Step 3: Stop the Application
```bash
ssh tour@192.168.1.149
cd /path/to/docker/compose # wherever docker-compose.yml is located
docker-compose down
```
### Step 4: Apply Schema Fix
```bash
ssh tour@192.168.1.149
cd /mnt/okcomputer/output
sqlite3 cache.db < /tmp/fix-schema.sql
```
### Step 5: Verify Schema
```bash
sqlite3 cache.db "PRAGMA table_info(auctions);"
# Should show: auction_id TEXT PRIMARY KEY
sqlite3 cache.db "PRAGMA table_info(lots);"
# Should show: lot_id TEXT PRIMARY KEY, sale_id TEXT, auction_id TEXT
# Check data integrity
sqlite3 cache.db "SELECT COUNT(*) FROM auctions;"
sqlite3 cache.db "SELECT COUNT(*) FROM lots;"
```
### Step 6: Rebuild Application with Fixed Schema
```bash
# Build new image with fixed DatabaseService.java
cd C:\vibe\auctiora
./mvnw clean package -DskipTests
# Copy new JAR to server
scp target/quarkus-app/quarkus-run.jar tour@192.168.1.149:/path/to/app/
```
### Step 7: Restart Application
```bash
ssh tour@192.168.1.149
cd /path/to/docker/compose
docker-compose up -d
```
### Step 8: Verify Fix
```bash
# Check logs for successful imports
docker-compose logs -f --tail=100
# Should see:
# ✓ Imported XXX auctions
# ✓ Imported XXXXX lots
# No more "PRIMARY KEY constraint failed" errors
# Check UI at http://192.168.1.149:8081/
# Should now show:
# - Lots with Bids: > 0
# - Total Bid Value: > €0.00
# - Average Bid: > €0.00
```
## Alternative: Quick Fix Without Downtime
If you can't afford downtime, delete the corrupted database and let it rebuild:
```bash
ssh tour@192.168.1.149
cd /mnt/okcomputer/output
mv cache.db cache.db.old
docker-compose restart
# The app will create a new database with correct schema
# Wait for scraper to re-populate data (may take 10-15 minutes)
```
## Files Changed
1. `DatabaseService.java` - Fixed schema definitions (auction_id, lot_id, sale_id now TEXT)
2. `fix-schema.sql` - SQL migration script to fix existing database
3. `SCHEMA_FIX_INSTRUCTIONS.md` - This file
## Testing Locally
Before deploying to server, test locally:
```bash
cd C:\vibe\auctiora
./mvnw clean test
# All tests should pass with new schema
```

View File

@@ -1,128 +0,0 @@
-- Schema Fix Script for Server Database
-- This script migrates auction_id and lot_id from BIGINT to TEXT to match scraper format
-- The scraper uses TEXT IDs like "A7-40063-2" but DatabaseService.java was creating BIGINT columns
-- Step 1: Backup existing data
CREATE TABLE IF NOT EXISTS auctions_backup AS SELECT * FROM auctions;
CREATE TABLE IF NOT EXISTS lots_backup AS SELECT * FROM lots;
CREATE TABLE IF NOT EXISTS images_backup AS SELECT * FROM images;
-- Step 2: Drop existing tables (CASCADE would drop foreign keys)
DROP TABLE IF EXISTS images;
DROP TABLE IF EXISTS lots;
DROP TABLE IF EXISTS auctions;
-- Step 3: Recreate auctions table with TEXT primary key (matching scraper format)
CREATE TABLE auctions (
auction_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
location TEXT,
city TEXT,
country TEXT,
url TEXT NOT NULL UNIQUE,
type TEXT,
lot_count INTEGER DEFAULT 0,
closing_time TEXT,
discovered_at INTEGER
);
-- Step 4: Recreate lots table with TEXT primary key (matching scraper format)
CREATE TABLE lots (
lot_id TEXT PRIMARY KEY,
sale_id TEXT,
auction_id TEXT,
title TEXT,
description TEXT,
manufacturer TEXT,
type TEXT,
year INTEGER,
category TEXT,
current_bid REAL,
currency TEXT DEFAULT 'EUR',
url TEXT UNIQUE,
closing_time TEXT,
closing_notified INTEGER DEFAULT 0,
starting_bid REAL,
minimum_bid REAL,
status TEXT,
brand TEXT,
model TEXT,
attributes_json TEXT,
first_bid_time TEXT,
last_bid_time TEXT,
bid_velocity REAL,
bid_increment REAL,
year_manufactured INTEGER,
condition_score REAL,
condition_description TEXT,
serial_number TEXT,
damage_description TEXT,
followers_count INTEGER DEFAULT 0,
estimated_min_price REAL,
estimated_max_price REAL,
lot_condition TEXT,
appearance TEXT,
estimated_min REAL,
estimated_max REAL,
next_bid_step_cents INTEGER,
condition TEXT,
category_path TEXT,
city_location TEXT,
country_code TEXT,
bidding_status TEXT,
packaging TEXT,
quantity INTEGER,
vat REAL,
buyer_premium_percentage REAL,
remarks TEXT,
reserve_price REAL,
reserve_met INTEGER,
view_count INTEGER,
bid_count INTEGER,
viewing_time TEXT,
pickup_date TEXT,
location TEXT,
scraped_at TEXT,
FOREIGN KEY (auction_id) REFERENCES auctions(auction_id),
FOREIGN KEY (sale_id) REFERENCES auctions(auction_id)
);
-- Step 5: Recreate images table
CREATE TABLE images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lot_id TEXT,
url TEXT,
local_path TEXT,
labels TEXT,
processed_at INTEGER,
downloaded INTEGER DEFAULT 0,
FOREIGN KEY (lot_id) REFERENCES lots(lot_id)
);
-- Step 6: Create bid_history table if it doesn't exist
CREATE TABLE IF NOT EXISTS bid_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lot_id TEXT,
bid_amount REAL,
bid_time TEXT,
is_autobid INTEGER DEFAULT 0,
bidder_id TEXT,
bidder_number INTEGER,
FOREIGN KEY (lot_id) REFERENCES lots(lot_id)
);
-- Step 7: Restore data from backup (converting BIGINT to TEXT if needed)
INSERT OR IGNORE INTO auctions SELECT * FROM auctions_backup;
INSERT OR IGNORE INTO lots SELECT * FROM lots_backup;
INSERT OR IGNORE INTO images SELECT * FROM images_backup;
-- Step 8: Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_auctions_country ON auctions(country);
CREATE INDEX IF NOT EXISTS idx_lots_sale_id ON lots(sale_id);
CREATE INDEX IF NOT EXISTS idx_lots_auction_id ON lots(auction_id);
CREATE INDEX IF NOT EXISTS idx_images_lot_id ON images(lot_id);
-- Step 9: Clean up backup tables (optional - comment out if you want to keep backups)
-- DROP TABLE auctions_backup;
-- DROP TABLE lots_backup;
-- DROP TABLE images_backup;

View File

@@ -175,6 +175,12 @@
</dependency> </dependency>
<!-- Mockito for mocking in tests --> <!-- Mockito for mocking in tests -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5-mockito</artifactId>
<version>3.30.2</version>
<scope>test</scope>
</dependency>
<dependency> <dependency>
<groupId>org.mockito</groupId> <groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId> <artifactId>mockito-core</artifactId>

View File

@@ -11,46 +11,29 @@ import org.eclipse.microprofile.health.Startup;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Paths; import java.nio.file.Paths;
/**
* Health checks for Auction Monitor.
* Provides liveness and readiness probes for Kubernetes/Docker deployment.
*/
@ApplicationScoped @ApplicationScoped
public class AuctionMonitorHealthCheck { public class AuctionMonitorHealthCheck {
@Inject
DatabaseService db;
/**
* Liveness probe - checks if application is alive
* GET /health/live
*/
@Liveness @Liveness
public static class LivenessCheck implements HealthCheck { public static class LivenessCheck
@Override implements HealthCheck {
public HealthCheckResponse call() {
@Override public HealthCheckResponse call() {
return HealthCheckResponse.up("Auction Monitor is alive"); return HealthCheckResponse.up("Auction Monitor is alive");
} }
} }
/**
* Readiness probe - checks if application is ready to serve requests
* GET /health/ready
*/
@Readiness @Readiness
@ApplicationScoped @ApplicationScoped
public static class ReadinessCheck implements HealthCheck { public static class ReadinessCheck
implements HealthCheck {
@Inject @Inject DatabaseService db;
DatabaseService db;
@Override @Override
public HealthCheckResponse call() { public HealthCheckResponse call() {
try { try {
// Check database connection
var auctions = db.getAllAuctions(); var auctions = db.getAllAuctions();
// Check database path exists
var dbPath = Paths.get("C:\\mnt\\okcomputer\\output\\cache.db"); var dbPath = Paths.get("C:\\mnt\\okcomputer\\output\\cache.db");
if (!Files.exists(dbPath.getParent())) { if (!Files.exists(dbPath.getParent())) {
return HealthCheckResponse.down("Database directory does not exist"); return HealthCheckResponse.down("Database directory does not exist");
@@ -70,16 +53,12 @@ public class AuctionMonitorHealthCheck {
} }
} }
/**
* Startup probe - checks if application has started correctly
* GET /health/started
*/
@Startup @Startup
@ApplicationScoped @ApplicationScoped
public static class StartupCheck implements HealthCheck { public static class StartupCheck
implements HealthCheck {
@Inject @Inject DatabaseService db;
DatabaseService db;
@Override @Override
public HealthCheckResponse call() { public HealthCheckResponse call() {

View File

@@ -22,9 +22,7 @@ public class AuctionMonitorProducer {
private static final Logger LOG = Logger.getLogger(AuctionMonitorProducer.class); private static final Logger LOG = Logger.getLogger(AuctionMonitorProducer.class);
@PostConstruct @PostConstruct void init() {
void init() {
// Load OpenCV native library at startup
try { try {
nu.pattern.OpenCV.loadLocally(); nu.pattern.OpenCV.loadLocally();
LOG.info("✓ OpenCV loaded successfully"); LOG.info("✓ OpenCV loaded successfully");
@@ -33,44 +31,30 @@ public class AuctionMonitorProducer {
} }
} }
@Produces @Produces @Singleton public DatabaseService produceDatabaseService(
@Singleton
public DatabaseService produceDatabaseService(
@ConfigProperty(name = "auction.database.path") String dbPath) throws SQLException { @ConfigProperty(name = "auction.database.path") String dbPath) throws SQLException {
LOG.infof("Initializing DatabaseService with path: %s", dbPath);
var db = new DatabaseService(dbPath); var db = new DatabaseService(dbPath);
db.ensureSchema(); db.ensureSchema();
return db; return db;
} }
@Produces @Produces @Singleton public NotificationService produceNotificationService(
@Singleton
public NotificationService produceNotificationService(
@ConfigProperty(name = "auction.notification.config") String config) { @ConfigProperty(name = "auction.notification.config") String config) {
LOG.infof("Initializing NotificationService with config: %s", config);
return new NotificationService(config); return new NotificationService(config);
} }
@Produces @Produces @Singleton public ObjectDetectionService produceObjectDetectionService(
@Singleton
public ObjectDetectionService produceObjectDetectionService(
@ConfigProperty(name = "auction.yolo.config") String cfgPath, @ConfigProperty(name = "auction.yolo.config") String cfgPath,
@ConfigProperty(name = "auction.yolo.weights") String weightsPath, @ConfigProperty(name = "auction.yolo.weights") String weightsPath,
@ConfigProperty(name = "auction.yolo.classes") String classesPath) throws IOException { @ConfigProperty(name = "auction.yolo.classes") String classesPath) throws IOException {
LOG.infof("Initializing ObjectDetectionService");
return new ObjectDetectionService(cfgPath, weightsPath, classesPath); return new ObjectDetectionService(cfgPath, weightsPath, classesPath);
} }
@Produces @Produces @Singleton public ImageProcessingService produceImageProcessingService(
@Singleton
public ImageProcessingService produceImageProcessingService(
DatabaseService db, DatabaseService db,
ObjectDetectionService detector) { ObjectDetectionService detector) {
LOG.infof("Initializing ImageProcessingService");
return new ImageProcessingService(db, detector); return new ImageProcessingService(db, detector);
} }
} }

View File

@@ -30,20 +30,11 @@ public class AuctionMonitorResource {
private static final Logger LOG = Logger.getLogger(AuctionMonitorResource.class); private static final Logger LOG = Logger.getLogger(AuctionMonitorResource.class);
@Inject @Inject DatabaseService db;
DatabaseService db; @Inject QuarkusWorkflowScheduler scheduler;
@Inject NotificationService notifier;
@Inject @Inject RateLimitedHttpClient httpClient;
QuarkusWorkflowScheduler scheduler; @Inject LotEnrichmentService enrichmentService;
@Inject
NotificationService notifier;
@Inject
RateLimitedHttpClient httpClient;
@Inject
LotEnrichmentService enrichmentService;
/** /**
* GET /api/monitor/status * GET /api/monitor/status

View File

@@ -165,7 +165,6 @@ public class DatabaseService {
} }
} }
/** /**
* Inserts or updates an auction record (typically called by external scraper) * Inserts or updates an auction record (typically called by external scraper)
* Handles both auction_id conflicts and url uniqueness constraints * Handles both auction_id conflicts and url uniqueness constraints
@@ -352,7 +351,7 @@ public class DatabaseService {
try (var conn = DriverManager.getConnection(url)) { try (var conn = DriverManager.getConnection(url)) {
// Try UPDATE first // Try UPDATE first
try (var ps = conn.prepareStatement(updateSql)) { try (var ps = conn.prepareStatement(updateSql)) {
ps.setLong(1, lot.saleId()); ps.setString(1, String.valueOf(lot.saleId()));
ps.setString(2, lot.title()); ps.setString(2, lot.title());
ps.setString(3, lot.description()); ps.setString(3, lot.description());
ps.setString(4, lot.manufacturer()); ps.setString(4, lot.manufacturer());
@@ -363,7 +362,7 @@ public class DatabaseService {
ps.setString(9, lot.currency()); ps.setString(9, lot.currency());
ps.setString(10, lot.url()); ps.setString(10, lot.url());
ps.setString(11, lot.closingTime() != null ? lot.closingTime().toString() : null); ps.setString(11, lot.closingTime() != null ? lot.closingTime().toString() : null);
ps.setLong(12, lot.lotId()); ps.setString(12, String.valueOf(lot.lotId()));
int updated = ps.executeUpdate(); int updated = ps.executeUpdate();
if (updated > 0) { if (updated > 0) {
@@ -373,8 +372,8 @@ public class DatabaseService {
// If no rows updated, try INSERT (ignore if conflicts with UNIQUE constraints) // If no rows updated, try INSERT (ignore if conflicts with UNIQUE constraints)
try (var ps = conn.prepareStatement(insertSql)) { try (var ps = conn.prepareStatement(insertSql)) {
ps.setLong(1, lot.lotId()); ps.setString(1, String.valueOf(lot.lotId()));
ps.setLong(2, lot.saleId()); ps.setString(2, String.valueOf(lot.saleId()));
ps.setString(3, lot.title()); ps.setString(3, lot.title());
ps.setString(4, lot.description()); ps.setString(4, lot.description());
ps.setString(5, lot.manufacturer()); ps.setString(5, lot.manufacturer());
@@ -449,10 +448,14 @@ public class DatabaseService {
ps.setString(11, lot.closingTime() != null ? lot.closingTime().toString() : null); ps.setString(11, lot.closingTime() != null ? lot.closingTime().toString() : null);
// Intelligence fields // Intelligence fields
if (lot.followersCount() != null) ps.setInt(12, lot.followersCount()); else ps.setNull(12, java.sql.Types.INTEGER); if (lot.followersCount() != null) ps.setInt(12, lot.followersCount());
if (lot.estimatedMin() != null) ps.setDouble(13, lot.estimatedMin()); else ps.setNull(13, java.sql.Types.REAL); else ps.setNull(12, java.sql.Types.INTEGER);
if (lot.estimatedMax() != null) ps.setDouble(14, lot.estimatedMax()); else ps.setNull(14, java.sql.Types.REAL); if (lot.estimatedMin() != null) ps.setDouble(13, lot.estimatedMin());
if (lot.nextBidStepInCents() != null) ps.setLong(15, lot.nextBidStepInCents()); else ps.setNull(15, java.sql.Types.BIGINT); else ps.setNull(13, java.sql.Types.REAL);
if (lot.estimatedMax() != null) ps.setDouble(14, lot.estimatedMax());
else ps.setNull(14, java.sql.Types.REAL);
if (lot.nextBidStepInCents() != null) ps.setLong(15, lot.nextBidStepInCents());
else ps.setNull(15, java.sql.Types.BIGINT);
ps.setString(16, lot.condition()); ps.setString(16, lot.condition());
ps.setString(17, lot.categoryPath()); ps.setString(17, lot.categoryPath());
ps.setString(18, lot.cityLocation()); ps.setString(18, lot.cityLocation());
@@ -460,18 +463,27 @@ public class DatabaseService {
ps.setString(20, lot.biddingStatus()); ps.setString(20, lot.biddingStatus());
ps.setString(21, lot.appearance()); ps.setString(21, lot.appearance());
ps.setString(22, lot.packaging()); ps.setString(22, lot.packaging());
if (lot.quantity() != null) ps.setLong(23, lot.quantity()); else ps.setNull(23, java.sql.Types.BIGINT); if (lot.quantity() != null) ps.setLong(23, lot.quantity());
if (lot.vat() != null) ps.setDouble(24, lot.vat()); else ps.setNull(24, java.sql.Types.REAL); else ps.setNull(23, java.sql.Types.BIGINT);
if (lot.buyerPremiumPercentage() != null) ps.setDouble(25, lot.buyerPremiumPercentage()); else ps.setNull(25, java.sql.Types.REAL); if (lot.vat() != null) ps.setDouble(24, lot.vat());
else ps.setNull(24, java.sql.Types.REAL);
if (lot.buyerPremiumPercentage() != null) ps.setDouble(25, lot.buyerPremiumPercentage());
else ps.setNull(25, java.sql.Types.REAL);
ps.setString(26, lot.remarks()); ps.setString(26, lot.remarks());
if (lot.startingBid() != null) ps.setDouble(27, lot.startingBid()); else ps.setNull(27, java.sql.Types.REAL); if (lot.startingBid() != null) ps.setDouble(27, lot.startingBid());
if (lot.reservePrice() != null) ps.setDouble(28, lot.reservePrice()); else ps.setNull(28, java.sql.Types.REAL); else ps.setNull(27, java.sql.Types.REAL);
if (lot.reserveMet() != null) ps.setInt(29, lot.reserveMet() ? 1 : 0); else ps.setNull(29, java.sql.Types.INTEGER); if (lot.reservePrice() != null) ps.setDouble(28, lot.reservePrice());
if (lot.bidIncrement() != null) ps.setDouble(30, lot.bidIncrement()); else ps.setNull(30, java.sql.Types.REAL); else ps.setNull(28, java.sql.Types.REAL);
if (lot.viewCount() != null) ps.setInt(31, lot.viewCount()); else ps.setNull(31, java.sql.Types.INTEGER); if (lot.reserveMet() != null) ps.setInt(29, lot.reserveMet() ? 1 : 0);
else ps.setNull(29, java.sql.Types.INTEGER);
if (lot.bidIncrement() != null) ps.setDouble(30, lot.bidIncrement());
else ps.setNull(30, java.sql.Types.REAL);
if (lot.viewCount() != null) ps.setInt(31, lot.viewCount());
else ps.setNull(31, java.sql.Types.INTEGER);
ps.setString(32, lot.firstBidTime() != null ? lot.firstBidTime().toString() : null); ps.setString(32, lot.firstBidTime() != null ? lot.firstBidTime().toString() : null);
ps.setString(33, lot.lastBidTime() != null ? lot.lastBidTime().toString() : null); ps.setString(33, lot.lastBidTime() != null ? lot.lastBidTime().toString() : null);
if (lot.bidVelocity() != null) ps.setDouble(34, lot.bidVelocity()); else ps.setNull(34, java.sql.Types.REAL); if (lot.bidVelocity() != null) ps.setDouble(34, lot.bidVelocity());
else ps.setNull(34, java.sql.Types.REAL);
ps.setLong(35, lot.lotId()); ps.setLong(35, lot.lotId());
@@ -603,7 +615,7 @@ public class DatabaseService {
try (var conn = DriverManager.getConnection(url); try (var conn = DriverManager.getConnection(url);
var ps = conn.prepareStatement("UPDATE lots SET current_bid = ? WHERE lot_id = ?")) { var ps = conn.prepareStatement("UPDATE lots SET current_bid = ? WHERE lot_id = ?")) {
ps.setDouble(1, lot.currentBid()); ps.setDouble(1, lot.currentBid());
ps.setLong(2, lot.lotId()); ps.setString(2, String.valueOf(lot.lotId()));
ps.executeUpdate(); ps.executeUpdate();
} }
} }
@@ -615,7 +627,7 @@ public class DatabaseService {
try (var conn = DriverManager.getConnection(url); try (var conn = DriverManager.getConnection(url);
var ps = conn.prepareStatement("UPDATE lots SET closing_notified = ? WHERE lot_id = ?")) { var ps = conn.prepareStatement("UPDATE lots SET closing_notified = ? WHERE lot_id = ?")) {
ps.setInt(1, lot.closingNotified() ? 1 : 0); ps.setInt(1, lot.closingNotified() ? 1 : 0);
ps.setLong(2, lot.lotId()); ps.setString(2, String.valueOf(lot.lotId()));
ps.executeUpdate(); ps.executeUpdate();
} }
} }
@@ -783,13 +795,7 @@ public class DatabaseService {
return images; return images;
} }
/**
* Simple record for image data from database
*/
record ImageRecord(int id, long lotId, String url, String filePath, String labels) { } record ImageRecord(int id, long lotId, String url, String filePath, String labels) { }
/**
* Record for images that need object detection processing
*/
record ImageDetectionRecord(int id, long lotId, String filePath) { } record ImageDetectionRecord(int id, long lotId, String filePath) { }
} }

View File

@@ -1,106 +1,55 @@
package auctiora; package auctiora;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.sql.SQLException;
import java.util.List;
/**
* Service responsible for processing images from the IMAGES table.
* Performs object detection on already-downloaded images and updates the database.
*
* NOTE: Image downloading is handled by the external scraper process.
* This service only performs object detection on images that already have local_path set.
*/
@Slf4j @Slf4j
class ImageProcessingService { public record ImageProcessingService(DatabaseService db, ObjectDetectionService detector) {
private final DatabaseService db; boolean processImage(int id, String path, long lot) {
private final ObjectDetectionService detector;
ImageProcessingService(DatabaseService db, ObjectDetectionService detector) {
this.db = db;
this.detector = detector;
}
/**
* Processes a single image: runs object detection and updates labels in database.
*
* @param imageId database ID of the image record
* @param localPath local file path to the downloaded image
* @param lotId lot identifier (for logging)
* @return true if processing succeeded
*/
boolean processImage(int imageId, String localPath, long lotId) {
try { try {
// Normalize path separators (convert Windows backslashes to forward slashes) path = path.replace('\\', '/');
localPath = localPath.replace('\\', '/'); var f = new java.io.File(path);
if (!f.exists() || !f.canRead()) {
// Check if file exists before processing log.warn("Image not accessible: {}", path);
var file = new java.io.File(localPath); return false;
if (!file.exists() || !file.canRead()) { }
log.warn(" Image file not accessible: {}", localPath); if (f.length() > 50L * 1024 * 1024) {
log.warn("Image too large: {}", path);
return false; return false;
} }
// Check file size (skip very large files that might cause issues) var labels = detector.detectObjects(path);
long fileSizeBytes = file.length(); db.updateImageLabels(id, labels);
if (fileSizeBytes > 50 * 1024 * 1024) { // 50 MB limit
log.warn(" Image file too large ({}MB): {}", fileSizeBytes / (1024 * 1024), localPath);
return false;
}
// Run object detection on the local file if (!labels.isEmpty())
var labels = detector.detectObjects(localPath); log.info("Lot {}: {}", lot, String.join(", ", labels));
// Update the database with detected labels
db.updateImageLabels(imageId, labels);
if (!labels.isEmpty()) {
log.info(" Lot {}: Detected {}", lotId, String.join(", ", labels));
}
return true; return true;
} catch (Exception e) { } catch (Exception e) {
log.error(" Failed to process image {}: {}", imageId, e.getMessage()); log.error("Process fail {}: {}", id, e.getMessage());
return false; return false;
} }
} }
/**
* Batch processes all pending images in the database.
* Only processes images that have been downloaded by the scraper but haven't had object detection run yet.
*/
void processPendingImages() { void processPendingImages() {
log.info("Processing pending images...");
try { try {
var pendingImages = db.getImagesNeedingDetection(); var images = db.getImagesNeedingDetection();
log.info("Found {} images needing object detection", pendingImages.size()); log.info("Pending {}", images.size());
var processed = 0; int processed = 0, detected = 0;
var detected = 0;
for (var image : pendingImages) { for (var i : images) {
if (processImage(image.id(), image.filePath(), image.lotId())) { if (processImage(i.id(), i.filePath(), i.lotId())) {
processed++; processed++;
// Re-fetch to check if labels were found var lbl = db.getImageLabels(i.id());
var labels = db.getImageLabels(image.id()); if (lbl != null && !lbl.isEmpty()) detected++;
if (labels != null && !labels.isEmpty()) {
detected++;
}
} }
} }
log.info("Processed {} images, detected objects in {}", processed, detected); log.info("Processed {}, detected {}", processed, detected);
} catch (SQLException e) { } catch (Exception e) {
log.error("Error processing pending images: {}", e.getMessage()); log.error("Batch fail: {}", e.getMessage());
} }
} }
} }

View File

@@ -17,8 +17,7 @@ import lombok.extern.slf4j.Slf4j;
@ApplicationScoped @ApplicationScoped
public class LotEnrichmentScheduler { public class LotEnrichmentScheduler {
@Inject @Inject LotEnrichmentService enrichmentService;
LotEnrichmentService enrichmentService;
/** /**
* Enriches lots closing within 1 hour - HIGH PRIORITY * Enriches lots closing within 1 hour - HIGH PRIORITY
@@ -29,9 +28,7 @@ public class LotEnrichmentScheduler {
try { try {
log.debug("Enriching critical lots (closing < 1 hour)"); log.debug("Enriching critical lots (closing < 1 hour)");
int enriched = enrichmentService.enrichClosingSoonLots(1); int enriched = enrichmentService.enrichClosingSoonLots(1);
if (enriched > 0) { if (enriched > 0) log.info("Enriched {} critical lots", enriched);
log.info("Enriched {} critical lots", enriched);
}
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to enrich critical lots", e); log.error("Failed to enrich critical lots", e);
} }
@@ -46,9 +43,7 @@ public class LotEnrichmentScheduler {
try { try {
log.debug("Enriching urgent lots (closing < 6 hours)"); log.debug("Enriching urgent lots (closing < 6 hours)");
int enriched = enrichmentService.enrichClosingSoonLots(6); int enriched = enrichmentService.enrichClosingSoonLots(6);
if (enriched > 0) { if (enriched > 0) log.info("Enriched {} urgent lots", enriched);
log.info("Enriched {} urgent lots", enriched);
}
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to enrich urgent lots", e); log.error("Failed to enrich urgent lots", e);
} }
@@ -63,9 +58,7 @@ public class LotEnrichmentScheduler {
try { try {
log.debug("Enriching daily lots (closing < 24 hours)"); log.debug("Enriching daily lots (closing < 24 hours)");
int enriched = enrichmentService.enrichClosingSoonLots(24); int enriched = enrichmentService.enrichClosingSoonLots(24);
if (enriched > 0) { if (enriched > 0) log.info("Enriched {} daily lots", enriched);
log.info("Enriched {} daily lots", enriched);
}
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to enrich daily lots", e); log.error("Failed to enrich daily lots", e);
} }

View File

@@ -17,12 +17,8 @@ import java.util.stream.Collectors;
@ApplicationScoped @ApplicationScoped
public class LotEnrichmentService { public class LotEnrichmentService {
@Inject @Inject TroostwijkGraphQLClient graphQLClient;
TroostwijkGraphQLClient graphQLClient; @Inject DatabaseService db;
@Inject
DatabaseService db;
/** /**
* Enriches a single lot with GraphQL intelligence data * Enriches a single lot with GraphQL intelligence data
*/ */

View File

@@ -1,231 +0,0 @@
package auctiora;
import lombok.extern.slf4j.Slf4j;
import org.opencv.core.Core;
/**
* Main entry point for Troostwijk Auction Monitor.
*
* ARCHITECTURE:
* This project focuses on:
* 1. Image processing and object detection
* 2. Bid monitoring and notifications
* 3. Data enrichment
*
* Auction/Lot scraping is handled by the external ARCHITECTURE-TROOSTWIJK-SCRAPER process.
* That process populates the auctions and lots tables in the shared database.
* This process reads from those tables and enriches them with:
* - Downloaded images
* - Object detection labels
* - Bid monitoring
* - Notifications
*/
@Slf4j
public class Main {
@SuppressWarnings("restricted")
private static Object loadOpenCV() {
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
return null;
}
public static void main(String[] args) throws Exception {
log.info("=== Troostwijk Auction Monitor ===\n");
// Parse command line arguments
var mode = args.length > 0 ? args[0] : "workflow";
// Configuration - Windows paths
var databaseFile = System.getenv().getOrDefault("DATABASE_FILE", "C:\\mnt\\okcomputer\\output\\cache.db");
var notificationConfig = System.getenv().getOrDefault("NOTIFICATION_CONFIG", "desktop");
// YOLO model paths (optional - monitor works without object detection)
var yoloCfg = "models/yolov4.cfg";
var yoloWeights = "models/yolov4.weights";
var yoloClasses = "models/coco.names";
// Load native OpenCV library (only if models exist)
try {
loadOpenCV();
log.info("✓ OpenCV loaded");
} catch (UnsatisfiedLinkError e) {
log.info("⚠️ OpenCV not available - image detection disabled");
}
switch (mode.toLowerCase()) {
case "workflow":
runWorkflowMode(databaseFile, notificationConfig, yoloCfg, yoloWeights, yoloClasses);
break;
case "once":
runOnceMode(databaseFile, notificationConfig, yoloCfg, yoloWeights, yoloClasses);
break;
case "legacy":
runLegacyMode(databaseFile, notificationConfig, yoloCfg, yoloWeights, yoloClasses);
break;
case "status":
showStatus(databaseFile, notificationConfig, yoloCfg, yoloWeights, yoloClasses);
break;
default:
showUsage();
break;
}
}
/**
* WORKFLOW MODE: Run orchestrated scheduled workflows (default)
* This is the recommended mode for production use.
*/
private static void runWorkflowMode(String dbPath, String notifConfig,
String yoloCfg, String yoloWeights, String yoloClasses)
throws Exception {
log.info("🚀 Starting in WORKFLOW MODE (Orchestrated Scheduling)\n");
var orchestrator = new WorkflowOrchestrator(
dbPath, notifConfig, yoloCfg, yoloWeights, yoloClasses
);
// Show initial status
orchestrator.printStatus();
// Start all scheduled workflows
orchestrator.startScheduledWorkflows();
log.info("✓ All workflows are running");
log.info(" - Scraper import: every 30 min");
log.info(" - Image processing: every 1 hour");
log.info(" - Bid monitoring: every 15 min");
log.info(" - Closing alerts: every 5 min");
log.info("\nPress Ctrl+C to stop.\n");
// Add shutdown hook
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
log.info("\n🛑 Shutdown signal received...");
orchestrator.shutdown();
}));
// Keep application alive
try {
Thread.sleep(Long.MAX_VALUE);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
orchestrator.shutdown();
}
}
/**
* ONCE MODE: Run complete workflow once and exit
* Useful for cron jobs or scheduled tasks.
*/
private static void runOnceMode(String dbPath, String notifConfig,
String yoloCfg, String yoloWeights, String yoloClasses)
throws Exception {
log.info("🔄 Starting in ONCE MODE (Single Execution)\n");
var orchestrator = new WorkflowOrchestrator(
dbPath, notifConfig, yoloCfg, yoloWeights, yoloClasses
);
orchestrator.runCompleteWorkflowOnce();
log.info("✓ Workflow execution completed. Exiting.\n");
}
/**
* LEGACY MODE: Original monitoring approach
* Kept for backward compatibility.
*/
private static void runLegacyMode(String dbPath, String notifConfig,
String yoloCfg, String yoloWeights, String yoloClasses)
throws Exception {
log.info("⚙️ Starting in LEGACY MODE\n");
var monitor = new TroostwijkMonitor(dbPath, notifConfig,
yoloCfg, yoloWeights, yoloClasses);
log.info("\n📊 Current Database State:");
monitor.printDatabaseStats();
log.info("\n[1/2] Processing images...");
monitor.processPendingImages();
log.info("\n[2/2] Starting bid monitoring...");
monitor.scheduleMonitoring();
log.info("\n✓ Monitor is running. Press Ctrl+C to stop.\n");
log.info("NOTE: This process expects auction/lot data from the external scraper.");
log.info(" Make sure ARCHITECTURE-TROOSTWIJK-SCRAPER is running and populating the database.\n");
try {
Thread.sleep(Long.MAX_VALUE);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.info("Monitor interrupted, exiting.");
}
}
/**
* STATUS MODE: Show current status and exit
*/
private static void showStatus(String dbPath, String notifConfig,
String yoloCfg, String yoloWeights, String yoloClasses)
throws Exception {
log.info("📊 Checking Status...\n");
var orchestrator = new WorkflowOrchestrator(
dbPath, notifConfig, yoloCfg, yoloWeights, yoloClasses
);
orchestrator.printStatus();
}
/**
* Show usage information
*/
private static void showUsage() {
log.info("Usage: java -jar troostwijk-monitor.jar [mode]\n");
log.info("Modes:");
log.info(" workflow - Run orchestrated scheduled workflows (default)");
log.info(" once - Run complete workflow once and exit (for cron)");
log.info(" legacy - Run original monitoring approach");
log.info(" status - Show current status and exit");
log.info("\nEnvironment Variables:");
log.info(" DATABASE_FILE - Path to SQLite database");
log.info(" (default: C:\\mnt\\okcomputer\\output\\cache.db)");
log.info(" NOTIFICATION_CONFIG - 'desktop' or 'smtp:user:pass:email'");
log.info(" (default: desktop)");
log.info("\nExamples:");
log.info(" java -jar troostwijk-monitor.jar workflow");
log.info(" java -jar troostwijk-monitor.jar once");
log.info(" java -jar troostwijk-monitor.jar status");
IO.println();
}
/**
* Alternative entry point for container environments.
* Simply keeps the container alive for manual commands.
*/
public static void main2(String[] args) {
if (args.length > 0) {
log.info("Command mode - exiting to allow shell commands");
return;
}
log.info("Troostwijk Monitor container is running and healthy.");
log.info("Use 'docker exec' or 'dokku run' to execute commands.");
try {
Thread.sleep(Long.MAX_VALUE);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.info("Container interrupted, exiting.");
}
}
}

View File

@@ -1,48 +1,49 @@
package auctiora; package auctiora;
import jakarta.enterprise.context.ApplicationScoped; import lombok.extern.slf4j.Slf4j;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import javax.mail.*; import javax.mail.*;
import javax.mail.internet.*; import javax.mail.internet.*;
import lombok.extern.slf4j.Slf4j;
import java.awt.*; import java.awt.*;
import java.util.Date; import java.util.Date;
import java.util.Properties; import java.util.Properties;
@Slf4j @Slf4j
public class NotificationService { public record NotificationService(Config cfg) {
private final Config config; // Extra convenience constructor: raw string → Config
public NotificationService(String raw) {
public NotificationService(String cfg) { this(Config.parse(raw));
this.config = Config.parse(cfg);
} }
public void sendNotification(String message, String title, int priority) { public void sendNotification(String msg, String title, int prio) {
if (config.useDesktop()) sendDesktop(title, message, priority); if (cfg.useDesktop()) sendDesktop(title, msg, prio);
if (config.useEmail()) sendEmail(title, message, priority); if (cfg.useEmail()) sendEmail(title, msg, prio);
} }
private void sendDesktop(String title, String msg, int prio) { private void sendDesktop(String title, String msg, int prio) {
try { try {
if (!SystemTray.isSupported()) { if (!SystemTray.isSupported()) {
log.info("Desktop notifications not supported — " + title + " / " + msg); log.info("Desktop not supported: {}", title);
return; return;
} }
var tray = SystemTray.getSystemTray(); var tray = SystemTray.getSystemTray();
var image = Toolkit.getDefaultToolkit().createImage(new byte[0]); var icon = new TrayIcon(
var trayIcon = new TrayIcon(image, "NotificationService"); Toolkit.getDefaultToolkit().createImage(new byte[0]),
trayIcon.setImageAutoSize(true); "notify"
);
icon.setImageAutoSize(true);
tray.add(icon);
var type = prio > 0 ? TrayIcon.MessageType.WARNING : TrayIcon.MessageType.INFO; var type = prio > 0 ? TrayIcon.MessageType.WARNING : TrayIcon.MessageType.INFO;
tray.add(trayIcon); icon.displayMessage(title, msg, type);
trayIcon.displayMessage(title, msg, type);
Thread.sleep(2000); Thread.sleep(2000);
tray.remove(trayIcon); tray.remove(icon);
log.info("Desktop notification sent: " + title); log.info("Desktop notification: {}", title);
} catch (Exception e) { } catch (Exception e) {
System.err.println("Desktop notification failed: " + e); log.warn("Desktop failed: {}", e.getMessage());
} }
} }
@@ -57,29 +58,32 @@ public class NotificationService {
var session = Session.getInstance(props, new Authenticator() { var session = Session.getInstance(props, new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() { protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(config.smtpUsername(), config.smtpPassword()); return new PasswordAuthentication(cfg.smtpUsername(), cfg.smtpPassword());
} }
}); });
var m = new MimeMessage(session); var m = new MimeMessage(session);
m.setFrom(new InternetAddress(config.smtpUsername())); m.setFrom(new InternetAddress(cfg.smtpUsername()));
m.setRecipients(Message.RecipientType.TO, InternetAddress.parse(config.toEmail())); m.setRecipients(Message.RecipientType.TO, InternetAddress.parse(cfg.toEmail()));
m.setSubject("[Troostwijk] " + title); m.setSubject("[Troostwijk] " + title);
m.setText(msg); m.setText(msg);
m.setSentDate(new Date()); m.setSentDate(new Date());
if (prio > 0) { if (prio > 0) {
m.setHeader("X-Priority", "1"); m.setHeader("X-Priority", "1");
m.setHeader("Importance", "High"); m.setHeader("Importance", "High");
} }
Transport.send(m); Transport.send(m);
log.info("Email notification sent: " + title); log.info("Email notification: {}", title);
} catch (Exception e) { } catch (Exception e) {
log.info("Email notification failed: " + e); log.warn("Email failed: {}", e.getMessage());
} }
} }
private record Config( public record Config(
boolean useDesktop, boolean useDesktop,
boolean useEmail, boolean useEmail,
String smtpUsername, String smtpUsername,
@@ -87,16 +91,20 @@ public class NotificationService {
String toEmail String toEmail
) { ) {
static Config parse(String cfg) { public static Config parse(String raw) {
if ("desktop".equalsIgnoreCase(cfg)) { if ("desktop".equalsIgnoreCase(raw)) {
return new Config(true, false, null, null, null); return new Config(true, false, null, null, null);
} else if (cfg.startsWith("smtp:")) {
var parts = cfg.split(":", -1); // Use -1 to include trailing empty strings
if (parts.length < 4)
throw new IllegalArgumentException("Email config must be 'smtp:username:password:toEmail'");
return new Config(true, true, parts[1], parts[2], parts[3]);
} }
throw new IllegalArgumentException("Config must be 'desktop' or 'smtp:username:password:toEmail'");
if (raw != null && raw.startsWith("smtp:")) {
var p = raw.split(":", -1);
if (p.length < 4) {
throw new IllegalArgumentException("Format: smtp:username:password:toEmail");
}
return new Config(true, true, p[1], p[2], p[3]);
}
throw new IllegalArgumentException("Use 'desktop' or 'smtp:username:password:toEmail'");
} }
} }
} }

View File

@@ -53,38 +53,38 @@ public class ObjectDetectionService {
log.info(" - {}", weightsPath); log.info(" - {}", weightsPath);
log.info(" - {}", classNamesPath); log.info(" - {}", classNamesPath);
log.info(" Scraper will continue without image analysis."); log.info(" Scraper will continue without image analysis.");
this.enabled = false; enabled = false;
this.net = null; net = null;
this.classNames = new ArrayList<>(); classNames = new ArrayList<>();
return; return;
} }
try { try {
// Load network // Load network
this.net = Dnn.readNetFromDarknet(cfgPath, weightsPath); net = Dnn.readNetFromDarknet(cfgPath, weightsPath);
// Try to use GPU/CUDA if available, fallback to CPU // Try to use GPU/CUDA if available, fallback to CPU
try { try {
this.net.setPreferableBackend(Dnn.DNN_BACKEND_CUDA); net.setPreferableBackend(Dnn.DNN_BACKEND_CUDA);
this.net.setPreferableTarget(Dnn.DNN_TARGET_CUDA); net.setPreferableTarget(Dnn.DNN_TARGET_CUDA);
log.info("✓ Object detection enabled with YOLO (CUDA/GPU acceleration)"); log.info("✓ Object detection enabled with YOLO (CUDA/GPU acceleration)");
} catch (Exception e) { } catch (Exception e) {
// CUDA not available, try Vulkan for AMD GPUs // CUDA not available, try Vulkan for AMD GPUs
try { try {
this.net.setPreferableBackend(Dnn.DNN_BACKEND_VKCOM); net.setPreferableBackend(Dnn.DNN_BACKEND_VKCOM);
this.net.setPreferableTarget(Dnn.DNN_TARGET_VULKAN); net.setPreferableTarget(Dnn.DNN_TARGET_VULKAN);
log.info("✓ Object detection enabled with YOLO (Vulkan/GPU acceleration)"); log.info("✓ Object detection enabled with YOLO (Vulkan/GPU acceleration)");
} catch (Exception e2) { } catch (Exception e2) {
// GPU not available, fallback to CPU // GPU not available, fallback to CPU
this.net.setPreferableBackend(DNN_BACKEND_OPENCV); net.setPreferableBackend(DNN_BACKEND_OPENCV);
this.net.setPreferableTarget(DNN_TARGET_CPU); net.setPreferableTarget(DNN_TARGET_CPU);
log.info("✓ Object detection enabled with YOLO (CPU only)"); log.info("✓ Object detection enabled with YOLO (CPU only)");
} }
} }
// Load class names (one per line) // Load class names (one per line)
this.classNames = Files.readAllLines(classNamesFile); classNames = Files.readAllLines(classNamesFile);
this.enabled = true; enabled = true;
} catch (UnsatisfiedLinkError e) { } catch (UnsatisfiedLinkError e) {
System.err.println("⚠️ Object detection disabled: OpenCV native libraries not loaded"); System.err.println("⚠️ Object detection disabled: OpenCV native libraries not loaded");
throw new IOException("Failed to initialize object detection: OpenCV native libraries not loaded", e); throw new IOException("Failed to initialize object detection: OpenCV native libraries not loaded", e);

View File

@@ -5,6 +5,7 @@ import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes; import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
@@ -22,23 +23,13 @@ public class QuarkusWorkflowScheduler {
private static final Logger LOG = Logger.getLogger(QuarkusWorkflowScheduler.class); private static final Logger LOG = Logger.getLogger(QuarkusWorkflowScheduler.class);
@Inject @Inject DatabaseService db;
DatabaseService db; @Inject NotificationService notifier;
@Inject ObjectDetectionService detector;
@Inject ImageProcessingService imageProcessor;
@Inject LotEnrichmentService enrichmentService;
@Inject @ConfigProperty(name = "auction.database.path") String databasePath;
NotificationService notifier;
@Inject
ObjectDetectionService detector;
@Inject
ImageProcessingService imageProcessor;
@Inject
LotEnrichmentService enrichmentService;
@ConfigProperty(name = "auction.database.path")
String databasePath;
/** /**
* Triggered on application startup to enrich existing lots with bid intelligence * Triggered on application startup to enrich existing lots with bid intelligence

View File

@@ -142,29 +142,15 @@ public class RateLimitedHttpClient {
* Determines max requests per second for a given host. * Determines max requests per second for a given host.
*/ */
private int getMaxRequestsPerSecond(String host) { private int getMaxRequestsPerSecond(String host) {
if (host.contains("troostwijk")) { return host.contains("troostwijk") ? troostwijkMaxRequestsPerSecond : defaultMaxRequestsPerSecond;
return troostwijkMaxRequestsPerSecond;
}
return defaultMaxRequestsPerSecond;
} }
/**
* Extracts host from URI (e.g., "api.troostwijkauctions.com").
*/
private String extractHost(URI uri) { private String extractHost(URI uri) {
return uri.getHost() != null ? uri.getHost() : uri.toString(); return uri.getHost() != null ? uri.getHost() : uri.toString();
} }
/**
* Gets statistics for all hosts.
*/
public Map<String, RequestStats> getAllStats() { public Map<String, RequestStats> getAllStats() {
return Map.copyOf(requestStats); return Map.copyOf(requestStats);
} }
/**
* Gets statistics for a specific host.
*/
public RequestStats getStats(String host) { public RequestStats getStats(String host) {
return requestStats.get(host); return requestStats.get(host);
} }
@@ -218,10 +204,7 @@ public class RateLimitedHttpClient {
} }
} }
/** public static final class RequestStats {
* Statistics tracker for HTTP requests per host.
*/
public static class RequestStats {
private final String host; private final String host;
private final AtomicLong totalRequests = new AtomicLong(0); private final AtomicLong totalRequests = new AtomicLong(0);
@@ -234,24 +217,15 @@ public class RateLimitedHttpClient {
this.host = host; this.host = host;
} }
void incrementTotal() { void incrementTotal() { totalRequests.incrementAndGet(); }
totalRequests.incrementAndGet();
}
void recordSuccess(long durationMs) { void recordSuccess(long durationMs) {
successfulRequests.incrementAndGet(); successfulRequests.incrementAndGet();
totalDurationMs.addAndGet(durationMs); totalDurationMs.addAndGet(durationMs);
} }
void incrementFailed() { void incrementFailed() { failedRequests.incrementAndGet(); }
failedRequests.incrementAndGet(); void incrementRateLimited() { rateLimitedRequests.incrementAndGet(); }
}
void incrementRateLimited() {
rateLimitedRequests.incrementAndGet();
}
// Getters
public String getHost() { return host; } public String getHost() { return host; }
public long getTotalRequests() { return totalRequests.get(); } public long getTotalRequests() { return totalRequests.get(); }
public long getSuccessfulRequests() { return successfulRequests.get(); } public long getSuccessfulRequests() { return successfulRequests.get(); }

View File

@@ -63,13 +63,15 @@ public class ScraperDataAdapter {
lotIdStr, // Store full displayId for GraphQL queries lotIdStr, // Store full displayId for GraphQL queries
rs.getString("title"), rs.getString("title"),
getStringOrDefault(rs, "description", ""), getStringOrDefault(rs, "description", ""),
"", "", 0, getStringOrDefault(rs, "manufacturer", ""),
getStringOrDefault(rs, "type", ""),
getIntOrDefault(rs, "year", 0),
getStringOrDefault(rs, "category", ""), getStringOrDefault(rs, "category", ""),
bid, bid,
currency, currency,
rs.getString("url"), rs.getString("url"),
closing, closing,
false, getBooleanOrDefault(rs, "closing_notified", false),
// New intelligence fields - set to null for now // New intelligence fields - set to null for now
null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null, null,
@@ -166,4 +168,9 @@ public class ScraperDataAdapter {
var v = rs.getInt(col); var v = rs.getInt(col);
return rs.wasNull() ? def : v; return rs.wasNull() ? def : v;
} }
private static boolean getBooleanOrDefault(ResultSet rs, String col, boolean def) throws SQLException {
var v = rs.getInt(col);
return rs.wasNull() ? def : v != 0;
}
} }

View File

@@ -5,7 +5,9 @@ import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces; import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import nu.pattern.OpenCV;
import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.opencv.core.Core;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
@@ -19,18 +21,11 @@ public class StatusResource {
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z") DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z")
.withZone(ZoneId.systemDefault()); .withZone(ZoneId.systemDefault());
@ConfigProperty(name = "application.version", defaultValue = "1.0-SNAPSHOT") @ConfigProperty(name = "application.version", defaultValue = "1.0-SNAPSHOT") String appVersion;
String appVersion; @ConfigProperty(name = "application.groupId") String groupId;
@ConfigProperty(name = "application.groupId") @ConfigProperty(name = "application.artifactId") String artifactId;
String groupId; @ConfigProperty(name = "application.version") String version;
@ConfigProperty(name = "application.artifactId")
String artifactId;
@ConfigProperty(name = "application.version")
String version;
// Java 16+ Record for structured response
public record StatusResponse( public record StatusResponse(
String groupId, String groupId,
String artifactId, String artifactId,
@@ -47,8 +42,6 @@ public class StatusResource {
@Path("/status") @Path("/status")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public StatusResponse getStatus() { public StatusResponse getStatus() {
log.info("Status endpoint called");
return new StatusResponse(groupId, artifactId, version, return new StatusResponse(groupId, artifactId, version,
"running", "running",
FORMATTER.format(Instant.now()), FORMATTER.format(Instant.now()),
@@ -63,8 +56,6 @@ public class StatusResource {
@Path("/hello") @Path("/hello")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
public Map<String, String> sayHello() { public Map<String, String> sayHello() {
log.info("hello endpoint called");
return Map.of( return Map.of(
"message", "Hello from Scrape-UI!", "message", "Hello from Scrape-UI!",
"timestamp", FORMATTER.format(Instant.now()), "timestamp", FORMATTER.format(Instant.now()),
@@ -74,9 +65,8 @@ public class StatusResource {
private String getOpenCvVersion() { private String getOpenCvVersion() {
try { try {
// Load OpenCV if not already loaded (safe to call multiple times) OpenCV.loadLocally();
nu.pattern.OpenCV.loadLocally(); return Core.VERSION;
return org.opencv.core.Core.VERSION;
} catch (Exception e) { } catch (Exception e) {
return "4.9.0 (default)"; return "4.9.0 (default)";
} }

View File

@@ -44,11 +44,11 @@ public class TroostwijkGraphQLClient {
} }
try { try {
String query = buildLotQuery(); var query = buildLotQuery();
String variables = buildVariables(displayId); var variables = buildVariables(displayId);
// Proper GraphQL request format with query and variables // Proper GraphQL request format with query and variables
String requestBody = String.format( var requestBody = String.format(
"{\"query\":\"%s\",\"variables\":%s}", "{\"query\":\"%s\",\"variables\":%s}",
escapeJson(query), escapeJson(query),
variables variables
@@ -87,14 +87,14 @@ public class TroostwijkGraphQLClient {
List<LotIntelligence> results = new ArrayList<>(); List<LotIntelligence> results = new ArrayList<>();
// Split into batches of 50 to avoid query size limits // Split into batches of 50 to avoid query size limits
int batchSize = 50; var batchSize = 50;
for (int i = 0; i < lotIds.size(); i += batchSize) { for (var i = 0; i < lotIds.size(); i += batchSize) {
int end = Math.min(i + batchSize, lotIds.size()); var end = Math.min(i + batchSize, lotIds.size());
List<Long> batch = lotIds.subList(i, end); var batch = lotIds.subList(i, end);
try { try {
String query = buildBatchLotQuery(batch); var query = buildBatchLotQuery(batch);
String requestBody = String.format("{\"query\":\"%s\"}", var requestBody = String.format("{\"query\":\"%s\"}",
escapeJson(query)); escapeJson(query));
var request = java.net.http.HttpRequest.newBuilder() var request = java.net.http.HttpRequest.newBuilder()
@@ -162,9 +162,9 @@ public class TroostwijkGraphQLClient {
} }
private String buildBatchLotQuery(List<Long> lotIds) { private String buildBatchLotQuery(List<Long> lotIds) {
StringBuilder query = new StringBuilder("query {"); var query = new StringBuilder("query {");
for (int i = 0; i < lotIds.size(); i++) { for (var i = 0; i < lotIds.size(); i++) {
query.append(String.format(""" query.append(String.format("""
lot%d: lot(id: %d) { lot%d: lot(id: %d) {
id id
@@ -197,8 +197,8 @@ public class TroostwijkGraphQLClient {
return null; return null;
} }
JsonNode root = objectMapper.readTree(json); var root = objectMapper.readTree(json);
JsonNode lotNode = root.path("data").path("lotDetails"); var lotNode = root.path("data").path("lotDetails");
if (lotNode.isMissingNode()) { if (lotNode.isMissingNode()) {
log.debug("No lotDetails in GraphQL response"); log.debug("No lotDetails in GraphQL response");
@@ -206,19 +206,19 @@ public class TroostwijkGraphQLClient {
} }
// Extract location from nested object // Extract location from nested object
JsonNode locationNode = lotNode.path("location"); var locationNode = lotNode.path("location");
String city = locationNode.isMissingNode() ? null : getStringOrNull(locationNode, "city"); var city = locationNode.isMissingNode() ? null : getStringOrNull(locationNode, "city");
String countryCode = locationNode.isMissingNode() ? null : getStringOrNull(locationNode, "country"); var countryCode = locationNode.isMissingNode() ? null : getStringOrNull(locationNode, "country");
// Extract bids count from nested biddingStatistics // Extract bids count from nested biddingStatistics
JsonNode statsNode = lotNode.path("biddingStatistics"); var statsNode = lotNode.path("biddingStatistics");
Integer bidsCount = statsNode.isMissingNode() ? null : getIntOrNull(statsNode, "numberOfBids"); var bidsCount = statsNode.isMissingNode() ? null : getIntOrNull(statsNode, "numberOfBids");
// Convert cents to euros for estimates // Convert cents to euros for estimates
Long estimatedMinCents = getLongOrNull(lotNode, "estimatedValueInCentsMin"); var estimatedMinCents = getLongOrNull(lotNode, "estimatedValueInCentsMin");
Long estimatedMaxCents = getLongOrNull(lotNode, "estimatedValueInCentsMax"); var estimatedMaxCents = getLongOrNull(lotNode, "estimatedValueInCentsMax");
Double estimatedMin = estimatedMinCents != null ? estimatedMinCents.doubleValue() : null; var estimatedMin = estimatedMinCents != null ? estimatedMinCents.doubleValue() : null;
Double estimatedMax = estimatedMaxCents != null ? estimatedMaxCents.doubleValue() : null; var estimatedMax = estimatedMaxCents != null ? estimatedMaxCents.doubleValue() : null;
return new LotIntelligence( return new LotIntelligence(
lotId, lotId,
@@ -257,11 +257,11 @@ public class TroostwijkGraphQLClient {
List<LotIntelligence> results = new ArrayList<>(); List<LotIntelligence> results = new ArrayList<>();
try { try {
JsonNode root = objectMapper.readTree(json); var root = objectMapper.readTree(json);
JsonNode data = root.path("data"); var data = root.path("data");
for (int i = 0; i < lotIds.size(); i++) { for (var i = 0; i < lotIds.size(); i++) {
JsonNode lotNode = data.path("lot" + i); var lotNode = data.path("lot" + i);
if (!lotNode.isMissingNode()) { if (!lotNode.isMissingNode()) {
var intelligence = parseLotIntelligenceFromNode(lotNode, lotIds.get(i)); var intelligence = parseLotIntelligenceFromNode(lotNode, lotIds.get(i));
if (intelligence != null) { if (intelligence != null) {
@@ -313,17 +313,17 @@ public class TroostwijkGraphQLClient {
private Double calculateBidVelocity(JsonNode lotNode) { private Double calculateBidVelocity(JsonNode lotNode) {
try { try {
Integer bidsCount = getIntOrNull(lotNode, "bidsCount"); var bidsCount = getIntOrNull(lotNode, "bidsCount");
String firstBidStr = getStringOrNull(lotNode, "firstBidTime"); var firstBidStr = getStringOrNull(lotNode, "firstBidTime");
if (bidsCount == null || firstBidStr == null || bidsCount == 0) { if (bidsCount == null || firstBidStr == null || bidsCount == 0) {
return null; return null;
} }
LocalDateTime firstBid = parseDateTime(firstBidStr); var firstBid = parseDateTime(firstBidStr);
if (firstBid == null) return null; if (firstBid == null) return null;
long hoursElapsed = java.time.Duration.between(firstBid, LocalDateTime.now()).toHours(); var hoursElapsed = java.time.Duration.between(firstBid, LocalDateTime.now()).toHours();
if (hoursElapsed == 0) return (double) bidsCount; if (hoursElapsed == 0) return (double) bidsCount;
return (double) bidsCount / hoursElapsed; return (double) bidsCount / hoursElapsed;
@@ -352,27 +352,27 @@ public class TroostwijkGraphQLClient {
} }
private Integer getIntOrNull(JsonNode node, String field) { private Integer getIntOrNull(JsonNode node, String field) {
JsonNode fieldNode = node.path(field); var fieldNode = node.path(field);
return fieldNode.isNumber() ? fieldNode.asInt() : null; return fieldNode.isNumber() ? fieldNode.asInt() : null;
} }
private Long getLongOrNull(JsonNode node, String field) { private Long getLongOrNull(JsonNode node, String field) {
JsonNode fieldNode = node.path(field); var fieldNode = node.path(field);
return fieldNode.isNumber() ? fieldNode.asLong() : null; return fieldNode.isNumber() ? fieldNode.asLong() : null;
} }
private Double getDoubleOrNull(JsonNode node, String field) { private Double getDoubleOrNull(JsonNode node, String field) {
JsonNode fieldNode = node.path(field); var fieldNode = node.path(field);
return fieldNode.isNumber() ? fieldNode.asDouble() : null; return fieldNode.isNumber() ? fieldNode.asDouble() : null;
} }
private String getStringOrNull(JsonNode node, String field) { private String getStringOrNull(JsonNode node, String field) {
JsonNode fieldNode = node.path(field); var fieldNode = node.path(field);
return fieldNode.isTextual() ? fieldNode.asText() : null; return fieldNode.isTextual() ? fieldNode.asText() : null;
} }
private Boolean getBooleanOrNull(JsonNode node, String field) { private Boolean getBooleanOrNull(JsonNode node, String field) {
JsonNode fieldNode = node.path(field); var fieldNode = node.path(field);
return fieldNode.isBoolean() ? fieldNode.asBoolean() : null; return fieldNode.isBoolean() ? fieldNode.asBoolean() : null;
} }
} }

View File

@@ -50,25 +50,25 @@ public class ValuationAnalyticsResource {
public Response calculateValuation(ValuationRequest request) { public Response calculateValuation(ValuationRequest request) {
try { try {
LOG.infof("Valuation request for lot: %s", request.lotId); LOG.infof("Valuation request for lot: %s", request.lotId);
long startTime = System.currentTimeMillis(); var startTime = System.currentTimeMillis();
// Step 1: Fetch comparable sales from database // Step 1: Fetch comparable sales from database
List<ComparableLot> comparables = fetchComparables(request); var comparables = fetchComparables(request);
// Step 2: Calculate Fair Market Value (FMV) // Step 2: Calculate Fair Market Value (FMV)
FairMarketValue fmv = calculateFairMarketValue(request, comparables); var fmv = calculateFairMarketValue(request, comparables);
// Step 3: Calculate undervaluation score // Step 3: Calculate undervaluation score
double undervaluationScore = calculateUndervaluationScore(request, fmv.value); var undervaluationScore = calculateUndervaluationScore(request, fmv.value);
// Step 4: Predict final price // Step 4: Predict final price
PricePrediction prediction = calculateFinalPrice(request, fmv.value); var prediction = calculateFinalPrice(request, fmv.value);
// Step 5: Generate bidding strategy // Step 5: Generate bidding strategy
BiddingStrategy strategy = generateBiddingStrategy(request, fmv, prediction); var strategy = generateBiddingStrategy(request, fmv, prediction);
// Step 6: Compile response // Step 6: Compile response
ValuationResponse response = new ValuationResponse(); var response = new ValuationResponse();
response.lotId = request.lotId; response.lotId = request.lotId;
response.timestamp = LocalDateTime.now().toString(); response.timestamp = LocalDateTime.now().toString();
response.fairMarketValue = fmv; response.fairMarketValue = fmv;
@@ -77,7 +77,7 @@ public class ValuationAnalyticsResource {
response.biddingStrategy = strategy; response.biddingStrategy = strategy;
response.parameters = request; response.parameters = request;
long duration = System.currentTimeMillis() - startTime; var duration = System.currentTimeMillis() - startTime;
LOG.infof("Valuation completed in %d ms", duration); LOG.infof("Valuation completed in %d ms", duration);
return Response.ok(response).build(); return Response.ok(response).build();
@@ -115,24 +115,24 @@ public class ValuationAnalyticsResource {
* Where weights are exponential/logistic functions of similarity * Where weights are exponential/logistic functions of similarity
*/ */
private FairMarketValue calculateFairMarketValue(ValuationRequest req, List<ComparableLot> comparables) { private FairMarketValue calculateFairMarketValue(ValuationRequest req, List<ComparableLot> comparables) {
double weightedSum = 0.0; var weightedSum = 0.0;
double weightSum = 0.0; var weightSum = 0.0;
List<WeightedComparable> weightedComps = new ArrayList<>(); List<WeightedComparable> weightedComps = new ArrayList<>();
for (ComparableLot comp : comparables) { for (var comp : comparables) {
// Condition weight: ω_c = exp(-λ_c · |C_target - C_i|) // Condition weight: ω_c = exp(-λ_c · |C_target - C_i|)
double omegaC = Math.exp(-0.693 * Math.abs(req.conditionScore - comp.conditionScore)); var omegaC = Math.exp(-0.693 * Math.abs(req.conditionScore - comp.conditionScore));
// Time weight: ω_t = exp(-λ_t · |T_target - T_i|) // Time weight: ω_t = exp(-λ_t · |T_target - T_i|)
double omegaT = Math.exp(-0.048 * Math.abs(req.manufacturingYear - comp.manufacturingYear)); var omegaT = Math.exp(-0.048 * Math.abs(req.manufacturingYear - comp.manufacturingYear));
// Provenance weight: ω_p = 1 + δ_p · (P_target - P_i) // Provenance weight: ω_p = 1 + δ_p · (P_target - P_i)
double omegaP = 1 + 0.15 * ((req.provenanceDocs > 0 ? 1 : 0) - comp.hasProvenance); var omegaP = 1 + 0.15 * ((req.provenanceDocs > 0 ? 1 : 0) - comp.hasProvenance);
// Historical weight: ω_h = 1 / (1 + e^(-kh · (D_i - D_median))) // Historical weight: ω_h = 1 / (1 + e^(-kh · (D_i - D_median)))
double omegaH = 1.0 / (1 + Math.exp(-0.01 * (comp.daysAgo - 40))); var omegaH = 1.0 / (1 + Math.exp(-0.01 * (comp.daysAgo - 40)));
double totalWeight = omegaC * omegaT * omegaP * omegaH; var totalWeight = omegaC * omegaT * omegaP * omegaH;
weightedSum += comp.finalPrice * totalWeight; weightedSum += comp.finalPrice * totalWeight;
weightSum += totalWeight; weightSum += totalWeight;
@@ -141,19 +141,19 @@ public class ValuationAnalyticsResource {
weightedComps.add(new WeightedComparable(comp, totalWeight, omegaC, omegaT, omegaP, omegaH)); weightedComps.add(new WeightedComparable(comp, totalWeight, omegaC, omegaT, omegaP, omegaH));
} }
double baseFMV = weightSum > 0 ? weightedSum / weightSum : (req.estimatedMin + req.estimatedMax) / 2; var baseFMV = weightSum > 0 ? weightedSum / weightSum : (req.estimatedMin + req.estimatedMax) / 2;
// Apply condition multiplier: M_cond = exp(α_c · √C_target - β_c) // Apply condition multiplier: M_cond = exp(α_c · √C_target - β_c)
double conditionMultiplier = Math.exp(0.15 * Math.sqrt(req.conditionScore) - 0.40); var conditionMultiplier = Math.exp(0.15 * Math.sqrt(req.conditionScore) - 0.40);
baseFMV *= conditionMultiplier; baseFMV *= conditionMultiplier;
// Apply provenance premium: Δ_prov = V_base · (η_0 + η_1 · ln(1 + N_docs)) // Apply provenance premium: Δ_prov = V_base · (η_0 + η_1 · ln(1 + N_docs))
if (req.provenanceDocs > 0) { if (req.provenanceDocs > 0) {
double provenancePremium = 0.08 + 0.035 * Math.log(1 + req.provenanceDocs); var provenancePremium = 0.08 + 0.035 * Math.log(1 + req.provenanceDocs);
baseFMV *= (1 + provenancePremium); baseFMV *= (1 + provenancePremium);
} }
FairMarketValue fmv = new FairMarketValue(); var fmv = new FairMarketValue();
fmv.value = Math.round(baseFMV * 100.0) / 100.0; fmv.value = Math.round(baseFMV * 100.0) / 100.0;
fmv.conditionMultiplier = Math.round(conditionMultiplier * 1000.0) / 1000.0; fmv.conditionMultiplier = Math.round(conditionMultiplier * 1000.0) / 1000.0;
fmv.provenancePremium = req.provenanceDocs > 0 ? 0.08 + 0.035 * Math.log(1 + req.provenanceDocs) : 0.0; fmv.provenancePremium = req.provenanceDocs > 0 ? 0.08 + 0.035 * Math.log(1 + req.provenanceDocs) : 0.0;
@@ -171,11 +171,11 @@ public class ValuationAnalyticsResource {
private double calculateUndervaluationScore(ValuationRequest req, double fmv) { private double calculateUndervaluationScore(ValuationRequest req, double fmv) {
if (fmv <= 0) return 0.0; if (fmv <= 0) return 0.0;
double priceGap = (fmv - req.currentBid) / fmv; var priceGap = (fmv - req.currentBid) / fmv;
double velocityFactor = 1 + req.bidVelocity / 10.0; var velocityFactor = 1 + req.bidVelocity / 10.0;
double watchRatio = Math.log(1 + req.watchCount / Math.max(req.bidCount, 1)); var watchRatio = Math.log(1 + req.watchCount / Math.max(req.bidCount, 1));
double uScore = priceGap * req.marketVolatility * velocityFactor * watchRatio; var uScore = priceGap * req.marketVolatility * velocityFactor * watchRatio;
return Math.max(0.0, Math.round(uScore * 1000.0) / 1000.0); return Math.max(0.0, Math.round(uScore * 1000.0) / 1000.0);
} }
@@ -186,22 +186,22 @@ public class ValuationAnalyticsResource {
*/ */
private PricePrediction calculateFinalPrice(ValuationRequest req, double fmv) { private PricePrediction calculateFinalPrice(ValuationRequest req, double fmv) {
// Bid momentum error: ε_bid = tanh(φ_1 · Λ_b - φ_2 · P_current/FMV) // Bid momentum error: ε_bid = tanh(φ_1 · Λ_b - φ_2 · P_current/FMV)
double epsilonBid = Math.tanh(0.15 * req.bidVelocity - 0.10 * (req.currentBid / fmv)); var epsilonBid = Math.tanh(0.15 * req.bidVelocity - 0.10 * (req.currentBid / fmv));
// Time pressure error: ε_time = ψ · exp(-t_close/30) // Time pressure error: ε_time = ψ · exp(-t_close/30)
double epsilonTime = 0.20 * Math.exp(-req.minutesUntilClose / 30.0); var epsilonTime = 0.20 * Math.exp(-req.minutesUntilClose / 30.0);
// Competition error: ε_comp = ρ · ln(1 + W_watch/50) // Competition error: ε_comp = ρ · ln(1 + W_watch/50)
double epsilonComp = 0.08 * Math.log(1 + req.watchCount / 50.0); var epsilonComp = 0.08 * Math.log(1 + req.watchCount / 50.0);
double predictedPrice = fmv * (1 + epsilonBid + epsilonTime + epsilonComp); var predictedPrice = fmv * (1 + epsilonBid + epsilonTime + epsilonComp);
// 95% confidence interval: ± 1.96 · σ_residual // 95% confidence interval: ± 1.96 · σ_residual
double residualStdDev = fmv * 0.08; // Mock residual standard deviation var residualStdDev = fmv * 0.08; // Mock residual standard deviation
double ciLower = predictedPrice - 1.96 * residualStdDev; var ciLower = predictedPrice - 1.96 * residualStdDev;
double ciUpper = predictedPrice + 1.96 * residualStdDev; var ciUpper = predictedPrice + 1.96 * residualStdDev;
PricePrediction pred = new PricePrediction(); var pred = new PricePrediction();
pred.predictedPrice = Math.round(predictedPrice * 100.0) / 100.0; pred.predictedPrice = Math.round(predictedPrice * 100.0) / 100.0;
pred.confidenceIntervalLower = Math.round(ciLower * 100.0) / 100.0; pred.confidenceIntervalLower = Math.round(ciLower * 100.0) / 100.0;
pred.confidenceIntervalUpper = Math.round(ciUpper * 100.0) / 100.0; pred.confidenceIntervalUpper = Math.round(ciUpper * 100.0) / 100.0;
@@ -218,7 +218,7 @@ public class ValuationAnalyticsResource {
* Generates optimal bidding strategy based on market conditions * Generates optimal bidding strategy based on market conditions
*/ */
private BiddingStrategy generateBiddingStrategy(ValuationRequest req, FairMarketValue fmv, PricePrediction pred) { private BiddingStrategy generateBiddingStrategy(ValuationRequest req, FairMarketValue fmv, PricePrediction pred) {
BiddingStrategy strategy = new BiddingStrategy(); var strategy = new BiddingStrategy();
// Determine competition level // Determine competition level
if (req.bidVelocity > 5.0) { if (req.bidVelocity > 5.0) {
@@ -236,7 +236,7 @@ public class ValuationAnalyticsResource {
strategy.recommendedTiming = "FINAL_10_MINUTES"; strategy.recommendedTiming = "FINAL_10_MINUTES";
// Adjust max bid based on undervaluation // Adjust max bid based on undervaluation
double undervaluationScore = calculateUndervaluationScore(req, fmv.value); var undervaluationScore = calculateUndervaluationScore(req, fmv.value);
if (undervaluationScore > 0.25) { if (undervaluationScore > 0.25) {
// Aggressive strategy for undervalued lots // Aggressive strategy for undervalued lots
strategy.maxBid = fmv.value * (1 + 0.05); // Conservative overbid strategy.maxBid = fmv.value * (1 + 0.05); // Conservative overbid
@@ -270,7 +270,7 @@ public class ValuationAnalyticsResource {
* Calculates confidence score based on number and quality of comparables * Calculates confidence score based on number and quality of comparables
*/ */
private double calculateFMVConfidence(int comparableCount, double totalWeight) { private double calculateFMVConfidence(int comparableCount, double totalWeight) {
double confidence = 0.5; // Base confidence var confidence = 0.5; // Base confidence
// Boost for more comparables // Boost for more comparables
confidence += Math.min(comparableCount * 0.05, 0.3); confidence += Math.min(comparableCount * 0.05, 0.3);

View File

@@ -22,6 +22,13 @@ class DatabaseServiceTest {
@BeforeAll @BeforeAll
void setUp() throws SQLException { void setUp() throws SQLException {
// Load SQLite JDBC driver
try {
Class.forName("org.sqlite.JDBC");
} catch (ClassNotFoundException e) {
throw new SQLException("SQLite JDBC driver not found", e);
}
testDbPath = "test_database_" + System.currentTimeMillis() + ".db"; testDbPath = "test_database_" + System.currentTimeMillis() + ".db";
db = new DatabaseService(testDbPath); db = new DatabaseService(testDbPath);
db.ensureSchema(); db.ensureSchema();

View File

@@ -20,37 +20,51 @@ class ImageProcessingServiceTest {
private DatabaseService mockDb; private DatabaseService mockDb;
private ObjectDetectionService mockDetector; private ObjectDetectionService mockDetector;
private ImageProcessingService service; private ImageProcessingService service;
private java.io.File testImage;
@BeforeEach @BeforeEach
void setUp() { void setUp() throws Exception {
mockDb = mock(DatabaseService.class); mockDb = mock(DatabaseService.class);
mockDetector = mock(ObjectDetectionService.class); mockDetector = mock(ObjectDetectionService.class);
service = new ImageProcessingService(mockDb, mockDetector); service = new ImageProcessingService(mockDb, mockDetector);
// Create a temporary test image file
testImage = java.io.File.createTempFile("test_image_", ".jpg");
testImage.deleteOnExit();
// Write minimal JPEG header to make it a valid file
try (var out = new java.io.FileOutputStream(testImage)) {
out.write(new byte[]{(byte)0xFF, (byte)0xD8, (byte)0xFF, (byte)0xE0});
}
} }
@Test @Test
@DisplayName("Should process single image and update labels") @DisplayName("Should process single image and update labels")
void testProcessImage() throws SQLException { void testProcessImage() throws SQLException {
// Mock object detection // Normalize path (convert backslashes to forward slashes)
when(mockDetector.detectObjects("/path/to/image.jpg")) String normalizedPath = testImage.getAbsolutePath().replace('\\', '/');
// Mock object detection with normalized path
when(mockDetector.detectObjects(normalizedPath))
.thenReturn(List.of("car", "vehicle")); .thenReturn(List.of("car", "vehicle"));
// Process image // Process image
boolean result = service.processImage(1, "/path/to/image.jpg", 12345); boolean result = service.processImage(1, testImage.getAbsolutePath(), 12345);
// Verify success // Verify success
assertTrue(result); assertTrue(result);
verify(mockDetector).detectObjects("/path/to/image.jpg"); verify(mockDetector).detectObjects(normalizedPath);
verify(mockDb).updateImageLabels(1, List.of("car", "vehicle")); verify(mockDb).updateImageLabels(1, List.of("car", "vehicle"));
} }
@Test @Test
@DisplayName("Should handle empty detection results") @DisplayName("Should handle empty detection results")
void testProcessImageWithNoDetections() throws SQLException { void testProcessImageWithNoDetections() throws SQLException {
when(mockDetector.detectObjects(anyString())) String normalizedPath = testImage.getAbsolutePath().replace('\\', '/');
when(mockDetector.detectObjects(normalizedPath))
.thenReturn(List.of()); .thenReturn(List.of());
boolean result = service.processImage(2, "/path/to/image2.jpg", 12346); boolean result = service.processImage(2, testImage.getAbsolutePath(), 12346);
assertTrue(result); assertTrue(result);
verify(mockDb).updateImageLabels(2, List.of()); verify(mockDb).updateImageLabels(2, List.of());
@@ -59,14 +73,16 @@ class ImageProcessingServiceTest {
@Test @Test
@DisplayName("Should handle database error gracefully") @DisplayName("Should handle database error gracefully")
void testProcessImageDatabaseError() throws SQLException { void testProcessImageDatabaseError() throws SQLException {
when(mockDetector.detectObjects(anyString())) String normalizedPath = testImage.getAbsolutePath().replace('\\', '/');
when(mockDetector.detectObjects(normalizedPath))
.thenReturn(List.of("object")); .thenReturn(List.of("object"));
doThrow(new SQLException("Database error")) doThrow(new SQLException("Database error"))
.when(mockDb).updateImageLabels(anyInt(), anyList()); .when(mockDb).updateImageLabels(anyInt(), anyList());
// Should return false on error // Should return false on error
boolean result = service.processImage(3, "/path/to/image3.jpg", 12347); boolean result = service.processImage(3, testImage.getAbsolutePath(), 12347);
assertFalse(result); assertFalse(result);
} }
@@ -84,13 +100,15 @@ class ImageProcessingServiceTest {
@Test @Test
@DisplayName("Should process pending images batch") @DisplayName("Should process pending images batch")
void testProcessPendingImages() throws SQLException { void testProcessPendingImages() throws SQLException {
// Mock pending images from database String normalizedPath = testImage.getAbsolutePath().replace('\\', '/');
// Mock pending images from database - use real test image path
when(mockDb.getImagesNeedingDetection()).thenReturn(List.of( when(mockDb.getImagesNeedingDetection()).thenReturn(List.of(
new DatabaseService.ImageDetectionRecord(1, 100L, "/images/100/001.jpg"), new DatabaseService.ImageDetectionRecord(1, 100L, testImage.getAbsolutePath()),
new DatabaseService.ImageDetectionRecord(2, 101L, "/images/101/001.jpg") new DatabaseService.ImageDetectionRecord(2, 101L, testImage.getAbsolutePath())
)); ));
when(mockDetector.detectObjects(anyString())) when(mockDetector.detectObjects(normalizedPath))
.thenReturn(List.of("item1")) .thenReturn(List.of("item1"))
.thenReturn(List.of("item2")); .thenReturn(List.of("item2"));
@@ -103,7 +121,7 @@ class ImageProcessingServiceTest {
// Verify all images were processed // Verify all images were processed
verify(mockDb).getImagesNeedingDetection(); verify(mockDb).getImagesNeedingDetection();
verify(mockDetector, times(2)).detectObjects(anyString()); verify(mockDetector, times(2)).detectObjects(normalizedPath);
verify(mockDb, times(2)).updateImageLabels(anyInt(), anyList()); verify(mockDb, times(2)).updateImageLabels(anyInt(), anyList());
} }
@@ -121,15 +139,16 @@ class ImageProcessingServiceTest {
@Test @Test
@DisplayName("Should continue processing after single image failure") @DisplayName("Should continue processing after single image failure")
void testProcessPendingImagesWithFailure() throws SQLException { void testProcessPendingImagesWithFailure() throws SQLException {
String normalizedPath = testImage.getAbsolutePath().replace('\\', '/');
when(mockDb.getImagesNeedingDetection()).thenReturn(List.of( when(mockDb.getImagesNeedingDetection()).thenReturn(List.of(
new DatabaseService.ImageDetectionRecord(1, 100L, "/images/100/001.jpg"), new DatabaseService.ImageDetectionRecord(1, 100L, testImage.getAbsolutePath()),
new DatabaseService.ImageDetectionRecord(2, 101L, "/images/101/001.jpg") new DatabaseService.ImageDetectionRecord(2, 101L, testImage.getAbsolutePath())
)); ));
// First image fails, second succeeds // First image fails, second succeeds
when(mockDetector.detectObjects("/images/100/001.jpg")) when(mockDetector.detectObjects(normalizedPath))
.thenThrow(new RuntimeException("Detection error")); .thenThrow(new RuntimeException("Detection error"))
when(mockDetector.detectObjects("/images/101/001.jpg"))
.thenReturn(List.of("item")); .thenReturn(List.of("item"));
when(mockDb.getImageLabels(2)) when(mockDb.getImageLabels(2))
@@ -138,7 +157,7 @@ class ImageProcessingServiceTest {
service.processPendingImages(); service.processPendingImages();
// Verify second image was still processed // Verify second image was still processed
verify(mockDetector, times(2)).detectObjects(anyString()); verify(mockDetector, times(2)).detectObjects(normalizedPath);
} }
@Test @Test
@@ -154,10 +173,12 @@ class ImageProcessingServiceTest {
@Test @Test
@DisplayName("Should process images with multiple detected objects") @DisplayName("Should process images with multiple detected objects")
void testProcessImageMultipleDetections() throws SQLException { void testProcessImageMultipleDetections() throws SQLException {
when(mockDetector.detectObjects(anyString())) String normalizedPath = testImage.getAbsolutePath().replace('\\', '/');
when(mockDetector.detectObjects(normalizedPath))
.thenReturn(List.of("car", "truck", "vehicle", "road")); .thenReturn(List.of("car", "truck", "vehicle", "road"));
boolean result = service.processImage(5, "/path/to/image5.jpg", 12349); boolean result = service.processImage(5, testImage.getAbsolutePath(), 12349);
assertTrue(result); assertTrue(result);
verify(mockDb).updateImageLabels(5, List.of("car", "truck", "vehicle", "road")); verify(mockDb).updateImageLabels(5, List.of("car", "truck", "vehicle", "road"));