diff --git a/src/main/java/group/goforward/battlbuilder/BattlBuilderApplication.java b/src/main/java/group/goforward/battlbuilder/BattlBuilderApplication.java index ea5b5f3..b190dda 100644 --- a/src/main/java/group/goforward/battlbuilder/BattlBuilderApplication.java +++ b/src/main/java/group/goforward/battlbuilder/BattlBuilderApplication.java @@ -8,8 +8,14 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @SpringBootApplication @EnableCaching -@EntityScan(basePackages = "group.goforward.battlbuilder.model") -@EnableJpaRepositories(basePackages = "group.goforward.battlbuilder.repos") +@EntityScan(basePackages = { + "group.goforward.battlbuilder.model", + "group.goforward.battlbuilder.enrichment" +}) +@EnableJpaRepositories(basePackages = { + "group.goforward.battlbuilder.repos", + "group.goforward.battlbuilder.enrichment" +}) public class BattlBuilderApplication { public static void main(String[] args) { diff --git a/src/main/java/group/goforward/battlbuilder/controllers/PartRoleMappingController.java b/src/main/java/group/goforward/battlbuilder/controllers/PartRoleMappingController.java index ad421ef..485d203 100644 --- a/src/main/java/group/goforward/battlbuilder/controllers/PartRoleMappingController.java +++ b/src/main/java/group/goforward/battlbuilder/controllers/PartRoleMappingController.java @@ -1,31 +1,31 @@ -package group.goforward.battlbuilder.controllers; - -import group.goforward.battlbuilder.services.PartRoleMappingService; -import group.goforward.battlbuilder.web.dto.admin.PartRoleMappingDto; -import group.goforward.battlbuilder.web.dto.PartRoleToCategoryDto; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -@RestController -@RequestMapping({"/api/part-role-mappings", "/api/v1/part-role-mappings"}) -public class PartRoleMappingController { - - private final PartRoleMappingService service; - - public PartRoleMappingController(PartRoleMappingService service) { - this.service = service; - } - - // Full view for admin UI - @GetMapping("/{platform}") - public List getMappings(@PathVariable String platform) { - return service.getMappingsForPlatform(platform); - } - - // Thin mapping for the builder - @GetMapping("/{platform}/map") - public List getRoleMap(@PathVariable String platform) { - return service.getRoleToCategoryMap(platform); - } -} \ No newline at end of file +//package group.goforward.battlbuilder.controllers; +// +//import group.goforward.battlbuilder.services.PartRoleMappingService; +//import group.goforward.battlbuilder.web.dto.admin.PartRoleMappingDto; +//import group.goforward.battlbuilder.web.dto.PartRoleToCategoryDto; +//import org.springframework.web.bind.annotation.*; +// +//import java.util.List; +// +//@RestController +//@RequestMapping({"/api/part-role-mappings", "/api/v1/part-role-mappings"}) +//public class PartRoleMappingController { +// +// private final PartRoleMappingService service; +// +// public PartRoleMappingController(PartRoleMappingService service) { +// this.service = service; +// } +// +// // Full view for admin UI +// @GetMapping("/{platform}") +// public List getMappings(@PathVariable String platform) { +// return service.getMappingsForPlatform(platform); +// } +// +// // Thin mapping for the builder +// @GetMapping("/{platform}/map") +// public List getRoleMap(@PathVariable String platform) { +// return service.getRoleToCategoryMap(platform); +// } +//} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/AdminEnrichmentController.java b/src/main/java/group/goforward/battlbuilder/enrichment/AdminEnrichmentController.java new file mode 100644 index 0000000..8b1f313 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/enrichment/AdminEnrichmentController.java @@ -0,0 +1,147 @@ +package group.goforward.battlbuilder.enrichment; + +import group.goforward.battlbuilder.enrichment.ai.AiEnrichmentOrchestrator; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/enrichment") +public class AdminEnrichmentController { + + private final CaliberEnrichmentService caliberEnrichmentService; + private final ProductEnrichmentRepository enrichmentRepository; + private final AiEnrichmentOrchestrator aiEnrichmentOrchestrator; + + public AdminEnrichmentController( + CaliberEnrichmentService caliberEnrichmentService, + ProductEnrichmentRepository enrichmentRepository, + AiEnrichmentOrchestrator aiEnrichmentOrchestrator + ) { + this.caliberEnrichmentService = caliberEnrichmentService; + this.enrichmentRepository = enrichmentRepository; + this.aiEnrichmentOrchestrator = aiEnrichmentOrchestrator; + } + + @PostMapping("/run") + public ResponseEntity run( + @RequestParam EnrichmentType type, + @RequestParam(defaultValue = "200") int limit + ) { + if (type != EnrichmentType.CALIBER) { + return ResponseEntity.badRequest().body("Only CALIBER is supported in v0."); + } + return ResponseEntity.ok(caliberEnrichmentService.runRules(limit)); + } + + // ✅ NEW: Run AI enrichment + @PostMapping("/ai/run") + public ResponseEntity runAi( + @RequestParam EnrichmentType type, + @RequestParam(defaultValue = "200") int limit + ) { + if (type != EnrichmentType.CALIBER) { + return ResponseEntity.badRequest().body("Only CALIBER is supported in v0."); + } + + // This should create ProductEnrichment rows with source=AI and status=PENDING_REVIEW + return ResponseEntity.ok(aiEnrichmentOrchestrator.runCaliber(limit)); + } + + @GetMapping("/queue") + public ResponseEntity> queue( + @RequestParam(defaultValue = "CALIBER") EnrichmentType type, + @RequestParam(defaultValue = "PENDING_REVIEW") EnrichmentStatus status, + @RequestParam(defaultValue = "100") int limit + ) { + var items = enrichmentRepository + .findByEnrichmentTypeAndStatusOrderByCreatedAtDesc(type, status, PageRequest.of(0, limit)); + return ResponseEntity.ok(items); + } + + @GetMapping("/queue2") + public ResponseEntity queue2( + @RequestParam(defaultValue = "CALIBER") EnrichmentType type, + @RequestParam(defaultValue = "PENDING_REVIEW") EnrichmentStatus status, + @RequestParam(defaultValue = "100") int limit + ) { + return ResponseEntity.ok( + enrichmentRepository.queueWithProduct(type, status, PageRequest.of(0, limit)) + ); + } + + @PostMapping("/{id}/approve") + public ResponseEntity approve(@PathVariable Long id) { + var e = enrichmentRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Not found: " + id)); + e.setStatus(EnrichmentStatus.APPROVED); + enrichmentRepository.save(e); + return ResponseEntity.ok(e); + } + + @PostMapping("/{id}/reject") + public ResponseEntity reject(@PathVariable Long id) { + var e = enrichmentRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Not found: " + id)); + e.setStatus(EnrichmentStatus.REJECTED); + enrichmentRepository.save(e); + return ResponseEntity.ok(e); + } + + @PostMapping("/{id}/apply") + @Transactional + public ResponseEntity apply(@PathVariable Long id) { + var e = enrichmentRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Not found: " + id)); + + if (e.getStatus() != EnrichmentStatus.APPROVED) { + return ResponseEntity.badRequest().body("Enrichment must be APPROVED before applying."); + } + + if (e.getEnrichmentType() == EnrichmentType.CALIBER) { + Object caliberObj = e.getAttributes().get("caliber"); + if (!(caliberObj instanceof String caliber) || caliber.trim().isEmpty()) { + return ResponseEntity.badRequest().body("Missing attributes.caliber"); + } + + String canonical = CaliberTaxonomy.normalizeCaliber(caliber.trim()); + int updated = enrichmentRepository.applyCaliberIfBlank(e.getProductId(), canonical); + + if (updated == 0) { + return ResponseEntity.badRequest().body("Product caliber already set (or product not found). Not applied."); + } + + // Bonus safety: set group if blank + String group = CaliberTaxonomy.groupForCaliber(canonical); + if (group != null && !group.isBlank()) { + enrichmentRepository.applyCaliberGroupIfBlank(e.getProductId(), group); + } + + } else if (e.getEnrichmentType() == EnrichmentType.CALIBER_GROUP) { + Object groupObj = e.getAttributes().get("caliberGroup"); + if (!(groupObj instanceof String group) || group.trim().isEmpty()) { + return ResponseEntity.badRequest().body("Missing attributes.caliberGroup"); + } + + int updated = enrichmentRepository.applyCaliberGroupIfBlank(e.getProductId(), group.trim()); + if (updated == 0) { + return ResponseEntity.badRequest().body("Product caliber_group already set (or product not found). Not applied."); + } + } else { + return ResponseEntity.badRequest().body("Unsupported enrichment type in v0."); + } + + e.setStatus(EnrichmentStatus.APPLIED); + enrichmentRepository.save(e); + + return ResponseEntity.ok(e); + } + + @PostMapping("/groups/run") + public ResponseEntity runGroups(@RequestParam(defaultValue = "200") int limit) { + return ResponseEntity.ok(aiEnrichmentOrchestrator.runCaliberGroup(limit)); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/CaliberEnrichmentService.java b/src/main/java/group/goforward/battlbuilder/enrichment/CaliberEnrichmentService.java new file mode 100644 index 0000000..c8f959d --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/enrichment/CaliberEnrichmentService.java @@ -0,0 +1,83 @@ +package group.goforward.battlbuilder.enrichment; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.List; +import java.util.Optional; + +@Service +public class CaliberEnrichmentService { + + private final ProductEnrichmentRepository enrichmentRepository; + + @PersistenceContext + private EntityManager em; + + private final CaliberRuleExtractor extractor = new CaliberRuleExtractor(); + + public CaliberEnrichmentService(ProductEnrichmentRepository enrichmentRepository) { + this.enrichmentRepository = enrichmentRepository; + } + + public record RunResult(int scanned, int created) {} + + /** + * Creates PENDING_REVIEW caliber enrichments for products that don't already have an active one. + */ + @Transactional + public RunResult runRules(int limit) { + // Adjust Product entity package if needed: + // IMPORTANT: Product must be a mapped @Entity named "Product" + List rows = em.createQuery(""" + select p.id, p.name, p.description + from Product p + where p.deletedAt is null + and not exists ( + select 1 from ProductEnrichment e + where e.productId = p.id + and e.enrichmentType = group.goforward.battlbuilder.enrichment.EnrichmentType.CALIBER + and e.status in ('PENDING_REVIEW','APPROVED') + ) + order by p.id desc + """, Object[].class) + .setMaxResults(limit) + .getResultList(); + + int created = 0; + + for (Object[] r : rows) { + Integer productId = (Integer) r[0]; + String name = (String) r[1]; + String description = (String) r[2]; + + Optional res = extractor.extract(name, description); + if (res.isEmpty()) continue; + + var result = res.get(); + + ProductEnrichment e = new ProductEnrichment(); + e.setProductId(productId); + e.setEnrichmentType(EnrichmentType.CALIBER); + e.setSource(EnrichmentSource.RULES); + e.setStatus(EnrichmentStatus.PENDING_REVIEW); + e.setSchemaVersion(1); + + var attrs = new HashMap(); + attrs.put("caliber", result.caliber()); + e.setAttributes(attrs); + + e.setConfidence(BigDecimal.valueOf(result.confidence())); + e.setRationale(result.rationale()); + + enrichmentRepository.save(e); + created++; + } + + return new RunResult(rows.size(), created); + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/CaliberRuleExtractor.java b/src/main/java/group/goforward/battlbuilder/enrichment/CaliberRuleExtractor.java new file mode 100644 index 0000000..23aae99 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/enrichment/CaliberRuleExtractor.java @@ -0,0 +1,42 @@ +package group.goforward.battlbuilder.enrichment; + +import java.util.Optional; + +public class CaliberRuleExtractor { + + public record Result(String caliber, String rationale, double confidence) {} + + // Keep this deliberately simple for v0. + public Optional extract(String name, String description) { + String hay = ((name == null ? "" : name) + " " + (description == null ? "" : description)).toLowerCase(); + + // Common AR-ish calibers (expand later) + if (containsAny(hay, "5.56", "5.56 nato", "5.56x45")) { + return Optional.of(new Result("5.56 NATO", "Detected token 5.56", 0.90)); + } + if (containsAny(hay, "223 wylde", ".223 wylde")) { + return Optional.of(new Result(".223 Wylde", "Detected token 223 Wylde", 0.92)); + } + if (containsAny(hay, ".223", "223 rem", "223 remington")) { + return Optional.of(new Result(".223 Remington", "Detected token .223 / 223 Rem", 0.88)); + } + if (containsAny(hay, "300 blk", "300 blackout", "300 aac")) { + return Optional.of(new Result(".300 Blackout", "Detected token 300 BLK/Blackout", 0.90)); + } + if (containsAny(hay, "6.5 grendel", "6.5g")) { + return Optional.of(new Result("6.5 Grendel", "Detected token 6.5 Grendel", 0.90)); + } + if (containsAny(hay, "9mm", "9x19")) { + return Optional.of(new Result("9mm", "Detected token 9mm/9x19", 0.85)); + } + + return Optional.empty(); + } + + private boolean containsAny(String hay, String... needles) { + for (String n : needles) { + if (hay.contains(n)) return true; + } + return false; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/CaliberTaxonomy.java b/src/main/java/group/goforward/battlbuilder/enrichment/CaliberTaxonomy.java new file mode 100644 index 0000000..0a27566 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/enrichment/CaliberTaxonomy.java @@ -0,0 +1,35 @@ +package group.goforward.battlbuilder.enrichment; + +import java.util.Locale; + +public final class CaliberTaxonomy { + private CaliberTaxonomy() {} + + public static String normalizeCaliber(String raw) { + if (raw == null) return null; + String s = raw.trim(); + + // Canonicalize common variants + String l = s.toLowerCase(Locale.ROOT); + + if (l.contains("223 wylde") || l.contains(".223 wylde")) return ".223 Wylde"; + if (l.contains("5.56") || l.contains("5,56") || l.contains("5.56x45") || l.contains("5.56x45mm")) return "5.56 NATO"; + if (l.contains("223") || l.contains(".223") || l.contains("223 rem") || l.contains("223 remington")) return ".223 Remington"; + + if (l.contains("300 blackout") || l.contains("300 blk") || l.contains("300 aac")) return "300 BLK"; + + // fallback: return trimmed original (you can tighten later) + return s; + } + + public static String groupForCaliber(String caliberCanonical) { + if (caliberCanonical == null) return null; + String l = caliberCanonical.toLowerCase(Locale.ROOT); + + if (l.contains("223") || l.contains("5.56") || l.contains("wylde")) return "223/5.56"; + if (l.contains("300 blk") || l.contains("300 blackout") || l.contains("300 aac")) return "300 BLK"; + + // TODO add more buckets: 308/7.62, 6.5 CM, 9mm, etc. + return null; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/EnrichmentSource.java b/src/main/java/group/goforward/battlbuilder/enrichment/EnrichmentSource.java new file mode 100644 index 0000000..8a52ca1 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/enrichment/EnrichmentSource.java @@ -0,0 +1,7 @@ +package group.goforward.battlbuilder.enrichment; + +public enum EnrichmentSource { + AI, + RULES, + HUMAN +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/EnrichmentStatus.java b/src/main/java/group/goforward/battlbuilder/enrichment/EnrichmentStatus.java new file mode 100644 index 0000000..de16e1d --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/enrichment/EnrichmentStatus.java @@ -0,0 +1,8 @@ +package group.goforward.battlbuilder.enrichment; + +public enum EnrichmentStatus { + PENDING_REVIEW, + APPROVED, + REJECTED, + APPLIED +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/EnrichmentType.java b/src/main/java/group/goforward/battlbuilder/enrichment/EnrichmentType.java new file mode 100644 index 0000000..0c9bc96 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/enrichment/EnrichmentType.java @@ -0,0 +1,11 @@ +package group.goforward.battlbuilder.enrichment; + +public enum EnrichmentType { + CALIBER, + CALIBER_GROUP, + BARREL_LENGTH, + GAS_SYSTEM, + HANDGUARD_LENGTH, + CONFIGURATION, + PART_ROLE +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/ProductEnrichment.java b/src/main/java/group/goforward/battlbuilder/enrichment/ProductEnrichment.java new file mode 100644 index 0000000..1520a61 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/enrichment/ProductEnrichment.java @@ -0,0 +1,92 @@ +package group.goforward.battlbuilder.enrichment; + +import jakarta.persistence.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.HashMap; +import java.util.Map; + +@Entity +@Table(name = "product_enrichments") +public class ProductEnrichment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_id", nullable = false) + private Integer productId; + + @Enumerated(EnumType.STRING) + @Column(name = "enrichment_type", nullable = false) + private EnrichmentType enrichmentType; + + @Enumerated(EnumType.STRING) + @Column(name = "source", nullable = false) + private EnrichmentSource source = EnrichmentSource.AI; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private EnrichmentStatus status = EnrichmentStatus.PENDING_REVIEW; + + @Column(name = "schema_version", nullable = false) + private Integer schemaVersion = 1; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "attributes", nullable = false, columnDefinition = "jsonb") + private Map attributes = new HashMap<>(); + + @Column(name = "confidence", precision = 4, scale = 3) + private BigDecimal confidence; + + @Column(name = "rationale") + private String rationale; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "meta", nullable = false, columnDefinition = "jsonb") + private Map meta = new HashMap<>(); + + // DB sets these defaults; we keep them read-only to avoid fighting the trigger/defaults + @Column(name = "created_at", insertable = false, updatable = false) + private OffsetDateTime createdAt; + + @Column(name = "updated_at", insertable = false, updatable = false) + private OffsetDateTime updatedAt; + + // --- getters/setters (generate via IDE) --- + + public Long getId() { return id; } + + public Integer getProductId() { return productId; } + public void setProductId(Integer productId) { this.productId = productId; } + + public EnrichmentType getEnrichmentType() { return enrichmentType; } + public void setEnrichmentType(EnrichmentType enrichmentType) { this.enrichmentType = enrichmentType; } + + public EnrichmentSource getSource() { return source; } + public void setSource(EnrichmentSource source) { this.source = source; } + + public EnrichmentStatus getStatus() { return status; } + public void setStatus(EnrichmentStatus status) { this.status = status; } + + public Integer getSchemaVersion() { return schemaVersion; } + public void setSchemaVersion(Integer schemaVersion) { this.schemaVersion = schemaVersion; } + + public Map getAttributes() { return attributes; } + public void setAttributes(Map attributes) { this.attributes = attributes; } + + public BigDecimal getConfidence() { return confidence; } + public void setConfidence(BigDecimal confidence) { this.confidence = confidence; } + + public String getRationale() { return rationale; } + public void setRationale(String rationale) { this.rationale = rationale; } + + public Map getMeta() { return meta; } + public void setMeta(Map meta) { this.meta = meta; } + + public OffsetDateTime getCreatedAt() { return createdAt; } + public OffsetDateTime getUpdatedAt() { return updatedAt; } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/ProductEnrichmentRepository.java b/src/main/java/group/goforward/battlbuilder/enrichment/ProductEnrichmentRepository.java new file mode 100644 index 0000000..051c2a8 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/enrichment/ProductEnrichmentRepository.java @@ -0,0 +1,91 @@ +package group.goforward.battlbuilder.enrichment; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.Modifying; +import group.goforward.battlbuilder.enrichment.dto.EnrichmentQueueItem; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface ProductEnrichmentRepository extends JpaRepository { + + boolean existsByProductIdAndEnrichmentTypeAndStatus( + Integer productId, + EnrichmentType enrichmentType, + EnrichmentStatus status + ); + + @Query(""" + select e from ProductEnrichment e + where e.productId = :productId + and e.enrichmentType = :type + and e.status in ('PENDING_REVIEW','APPROVED') + """) + Optional findActive(Integer productId, EnrichmentType type); + + List findByEnrichmentTypeAndStatusOrderByCreatedAtDesc( + EnrichmentType type, + EnrichmentStatus status, + Pageable pageable + ); + + @Query(""" + select new group.goforward.battlbuilder.enrichment.dto.EnrichmentQueueItem( + e.id, + e.productId, + p.name, + p.slug, + p.mainImageUrl, + b.name, + e.enrichmentType, + e.source, + e.status, + e.schemaVersion, + e.attributes, + e.confidence, + e.rationale, + e.createdAt, + p.caliber, + p.caliberGroup + + ) + from ProductEnrichment e + join Product p on p.id = e.productId + join p.brand b + where e.enrichmentType = :type + and e.status = :status + order by e.createdAt desc + """) + List queueWithProduct( + EnrichmentType type, + EnrichmentStatus status, + Pageable pageable + ); + + @Modifying + @Query(""" + update Product p + set p.caliber = :caliber + where p.id = :productId + and (p.caliber is null or trim(p.caliber) = '') + """) + int applyCaliberIfBlank( + @Param("productId") Integer productId, + @Param("caliber") String caliber + ); + + @Modifying + @Query(""" + update Product p + set p.caliberGroup = :caliberGroup + where p.id = :productId + and (p.caliberGroup is null or trim(p.caliberGroup) = '') +""") + int applyCaliberGroupIfBlank( + @Param("productId") Integer productId, + @Param("caliberGroup") String caliberGroup + ); +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/ai/AiEnrichmentOrchestrator.java b/src/main/java/group/goforward/battlbuilder/enrichment/ai/AiEnrichmentOrchestrator.java new file mode 100644 index 0000000..f5b4be4 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/enrichment/ai/AiEnrichmentOrchestrator.java @@ -0,0 +1,100 @@ +package group.goforward.battlbuilder.enrichment.ai; + +import group.goforward.battlbuilder.enrichment.*; +import group.goforward.battlbuilder.enrichment.ai.dto.CaliberExtractionResult; +import group.goforward.battlbuilder.model.Product; +import group.goforward.battlbuilder.repos.ProductRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +@Service +public class AiEnrichmentOrchestrator { + + private final EnrichmentModelClient modelClient; + private final ProductRepository productRepository; + private final ProductEnrichmentRepository enrichmentRepository; + + @Value("${ai.minConfidence:0.75}") + private BigDecimal minConfidence; + + public AiEnrichmentOrchestrator( + EnrichmentModelClient modelClient, + ProductRepository productRepository, + ProductEnrichmentRepository enrichmentRepository + ) { + this.modelClient = modelClient; + this.productRepository = productRepository; + this.enrichmentRepository = enrichmentRepository; + } + + public int runCaliber(int limit) { + // pick candidates: caliber missing + List candidates = productRepository.findProductsMissingCaliber(limit); + + int created = 0; + + for (Product p : candidates) { + CaliberExtractionResult r = modelClient.extractCaliber(p); + + if (r == null || !r.isUsable(minConfidence)) { + continue; + } + + // Optional: avoid duplicates for same product/type/status + boolean exists = enrichmentRepository.existsByProductIdAndEnrichmentTypeAndStatus( + p.getId(), EnrichmentType.CALIBER, EnrichmentStatus.PENDING_REVIEW + ); + if (exists) continue; + + ProductEnrichment pe = new ProductEnrichment(); + pe.setProductId(p.getId()); + pe.setEnrichmentType(EnrichmentType.CALIBER); + pe.setSource(EnrichmentSource.AI); + pe.setStatus(EnrichmentStatus.PENDING_REVIEW); + pe.setSchemaVersion(1); + pe.setAttributes(Map.of("caliber", r.caliber())); + pe.setConfidence(r.confidence()); + pe.setRationale(r.reason()); + pe.setMeta(Map.of("provider", modelClient.providerName())); + + enrichmentRepository.save(pe); + created++; + } + + return created; + } + public int runCaliberGroup(int limit) { + List candidates = productRepository.findProductsMissingCaliberGroup(limit); + int created = 0; + + for (Product p : candidates) { + String group = CaliberTaxonomy.groupForCaliber(p.getCaliber()); + if (group == null || group.isBlank()) continue; + + boolean exists = enrichmentRepository.existsByProductIdAndEnrichmentTypeAndStatus( + p.getId(), EnrichmentType.CALIBER_GROUP, EnrichmentStatus.PENDING_REVIEW + ); + if (exists) continue; + + ProductEnrichment pe = new ProductEnrichment(); + pe.setProductId(p.getId()); + pe.setEnrichmentType(EnrichmentType.CALIBER_GROUP); + pe.setSource(EnrichmentSource.RULES); // derived rules + pe.setStatus(EnrichmentStatus.PENDING_REVIEW); + pe.setSchemaVersion(1); + pe.setAttributes(java.util.Map.of("caliberGroup", group)); + pe.setConfidence(new java.math.BigDecimal("1.00")); + pe.setRationale("Derived caliberGroup from product.caliber via CaliberTaxonomy"); + pe.setMeta(java.util.Map.of("provider", "TAXONOMY")); + + enrichmentRepository.save(pe); + created++; + } + + return created; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/ai/ClaudeEnrichmentClient.java b/src/main/java/group/goforward/battlbuilder/enrichment/ai/ClaudeEnrichmentClient.java new file mode 100644 index 0000000..6b090f7 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/enrichment/ai/ClaudeEnrichmentClient.java @@ -0,0 +1,24 @@ +package group.goforward.battlbuilder.enrichment.ai; + +import group.goforward.battlbuilder.enrichment.ai.dto.CaliberExtractionResult; +import group.goforward.battlbuilder.model.Product; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; + +@Component +@Profile("claude") +public class ClaudeEnrichmentClient implements EnrichmentModelClient { + + @Override + public CaliberExtractionResult extractCaliber(Product p) { + // TODO: call Anthropic Claude and parse response + return new CaliberExtractionResult(null, BigDecimal.ZERO, "Not implemented"); + } + + @Override + public String providerName() { + return "CLAUDE"; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/ai/EnrichmentModelClient.java b/src/main/java/group/goforward/battlbuilder/enrichment/ai/EnrichmentModelClient.java new file mode 100644 index 0000000..927f4eb --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/enrichment/ai/EnrichmentModelClient.java @@ -0,0 +1,9 @@ +package group.goforward.battlbuilder.enrichment.ai; + +import group.goforward.battlbuilder.model.Product; +import group.goforward.battlbuilder.enrichment.ai.dto.CaliberExtractionResult; + +public interface EnrichmentModelClient { + CaliberExtractionResult extractCaliber(Product product); + String providerName(); +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/ai/OpenAiEnrichmentClient.java b/src/main/java/group/goforward/battlbuilder/enrichment/ai/OpenAiEnrichmentClient.java new file mode 100644 index 0000000..a3977f7 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/enrichment/ai/OpenAiEnrichmentClient.java @@ -0,0 +1,115 @@ +package group.goforward.battlbuilder.enrichment.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import group.goforward.battlbuilder.enrichment.ai.dto.CaliberExtractionResult; +import group.goforward.battlbuilder.model.Product; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import jakarta.annotation.PostConstruct; +import java.math.BigDecimal; +import java.util.Map; + +@Component +@Profile("openai") // recommend explicit profile so it's unambiguous +public class OpenAiEnrichmentClient implements EnrichmentModelClient { + + @Value("${ai.openai.apiKey:}") + private String apiKey; + + @Value("${ai.openai.model:gpt-4.1-mini}") + private String model; + + private final ObjectMapper om = new ObjectMapper(); + private RestClient client; + + @PostConstruct + void init() { + this.client = RestClient.builder() + .baseUrl("https://api.openai.com/v1") + .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + + System.out.println("✅ OpenAiEnrichmentClient loaded. model=" + model + + ", apiKeyPresent=" + (apiKey != null && !apiKey.isBlank())); + } + + @Override + public CaliberExtractionResult extractCaliber(Product p) { + if (apiKey == null || apiKey.isBlank()) { + return new CaliberExtractionResult(null, BigDecimal.ZERO, "Missing OpenAI apiKey"); + } + + // Keep prompt small + deterministic + String productText = safe(p.getName()) + + "\nBrand: " + safe(p.getBrand() != null ? p.getBrand().getName() : null) + + "\nMPN: " + safe(p.getMpn()) + + "\nUPC: " + safe(p.getUpc()) + + "\nPlatform: " + safe(p.getPlatform()) + + "\nPartRole: " + safe(p.getPartRole()); + + String system = """ + You extract firearm caliber from product listing text. + Return ONLY valid JSON with keys: caliber, confidence, reason. + caliber must be a short normalized string like "5.56 NATO", "223 Wylde", "300 Blackout", "9mm", "6.5 Creedmoor". + confidence is 0.0 to 1.0. + If unknown, set caliber to null and confidence to 0. + """; + + String user = """ + Extract the caliber from this product text: + --- + %s + --- + """.formatted(productText); + + Map body = Map.of( + "model", model, + "messages", new Object[]{ + Map.of("role", "system", "content", system), + Map.of("role", "user", "content", user) + }, + "temperature", 0 + ); + + try { + String raw = client.post() + .uri("/chat/completions") + .body(body) + .retrieve() + .body(String.class); + + JsonNode root = om.readTree(raw); + String content = root.at("/choices/0/message/content").asText(""); + + // content should be JSON. Parse it. + JsonNode out = om.readTree(content); + + String caliber = out.hasNonNull("caliber") ? out.get("caliber").asText() : null; + BigDecimal confidence = out.hasNonNull("confidence") + ? new BigDecimal(out.get("confidence").asText("0")) + : BigDecimal.ZERO; + String reason = out.hasNonNull("reason") ? out.get("reason").asText() : ""; + + return new CaliberExtractionResult(caliber, confidence, reason); + + } catch (Exception e) { + return new CaliberExtractionResult(null, BigDecimal.ZERO, "OpenAI error: " + e.getMessage()); + } + } + + @Override + public String providerName() { + return "OPENAI"; + } + + private static String safe(String s) { + return (s == null || s.isBlank()) ? "—" : s; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/ai/dto/CaliberExtractionResult.java b/src/main/java/group/goforward/battlbuilder/enrichment/ai/dto/CaliberExtractionResult.java new file mode 100644 index 0000000..92ea035 --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/enrichment/ai/dto/CaliberExtractionResult.java @@ -0,0 +1,16 @@ +package group.goforward.battlbuilder.enrichment.ai.dto; + +import java.math.BigDecimal; + +public record CaliberExtractionResult( + String caliber, + BigDecimal confidence, + String reason +) { + public boolean isUsable(BigDecimal minConfidence) { + return caliber != null + && !caliber.isBlank() + && confidence != null + && confidence.compareTo(minConfidence) >= 0; + } +} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/enrichment/dto/EnrichmentQueueItem.java b/src/main/java/group/goforward/battlbuilder/enrichment/dto/EnrichmentQueueItem.java new file mode 100644 index 0000000..c1f6d7f --- /dev/null +++ b/src/main/java/group/goforward/battlbuilder/enrichment/dto/EnrichmentQueueItem.java @@ -0,0 +1,27 @@ +package group.goforward.battlbuilder.enrichment.dto; + +import group.goforward.battlbuilder.enrichment.*; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.Map; + +public record EnrichmentQueueItem( + Long id, + Integer productId, + String productName, + String productSlug, + String mainImageUrl, + String brandName, + EnrichmentType enrichmentType, + EnrichmentSource source, + EnrichmentStatus status, + Integer schemaVersion, + Map attributes, + BigDecimal confidence, + String rationale, + OffsetDateTime createdAt, + String productCaliber, + String productCaliberGroup + +) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/battlbuilder/model/Product.java b/src/main/java/group/goforward/battlbuilder/model/Product.java index e9c9834..4244bb4 100644 --- a/src/main/java/group/goforward/battlbuilder/model/Product.java +++ b/src/main/java/group/goforward/battlbuilder/model/Product.java @@ -99,6 +99,18 @@ public class Product { @OneToMany(mappedBy = "product", fetch = FetchType.LAZY) private Set offers = new HashSet<>(); + @Column(name = "caliber") + private String caliber; + + public String getCaliber() { return caliber; } + public void setCaliber(String caliber) { this.caliber = caliber; } + + @Column(name = "caliber_group") + private String caliberGroup; + + public String getCaliberGroup() { return caliberGroup; } + public void setCaliberGroup(String caliberGroup) { this.caliberGroup = caliberGroup; } + // --- lifecycle hooks --- @PrePersist public void prePersist() { diff --git a/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java b/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java index 6cfeb64..c0fa3e6 100644 --- a/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java +++ b/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java @@ -248,5 +248,50 @@ public interface ProductRepository extends JpaRepository { """) Page findNeedingBattlImageUrlMigration(Pageable pageable); + // ------------------------------------------------- + // Enrichment: find products missing caliber and NOT already queued + // ------------------------------------------------- + @Query(value = """ + select p.* + from products p + where p.deleted_at is null + and (p.caliber is null or btrim(p.caliber) = '') + and not exists ( + select 1 + from product_enrichments pe + where pe.product_id = p.id + and pe.enrichment_type = 'CALIBER' + and pe.status in ('PENDING_REVIEW', 'APPROVED', 'APPLIED') + ) + order by p.id asc + limit :limit + """, nativeQuery = true) + List findProductsMissingCaliberNotQueued(@Param("limit") int limit); + + // ------------------------------------------------- + // Enrichment: find products missing caliber + // ------------------------------------------------- + @Query(value = """ + select p.* + from products p + where p.deleted_at is null + and (p.caliber is null or btrim(p.caliber) = '') + order by p.id asc + limit :limit + """, nativeQuery = true) + List findProductsMissingCaliber(@Param("limit") int limit); + + // ------------------------------------------------- + // Enrichment: find products missing caliber group + // ------------------------------------------------- + @Query(value = """ + select * + from products p + where p.deleted_at is null + and (p.caliber_group is null or trim(p.caliber_group) = '') + and p.caliber is not null and trim(p.caliber) <> '' + limit :limit +""", nativeQuery = true) + List findProductsMissingCaliberGroup(@Param("limit") int limit); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d045b09..9d14611 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -53,4 +53,10 @@ app.publicBaseUrl=http://localhost:3000 app.authTokenPepper=9f2f3785b59eb04b49ce08b6faffc9d01a03851018f783229e829ec0d9d01349e3fd8f216c3b87ebcafbf3610f7d151ba3cd54434b907cb5a8eab6d015a826cb # Magic Token Duration -security.jwt.access-token-days=30 \ No newline at end of file +security.jwt.access-token-days=30 + +# Ai Enrichment Settings +ai.minConfidence=0.75 + +ai.openai.apiKey=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 +ai.openai.model=gpt-4.1-mini \ No newline at end of file