working caliber mapping with ai enrichment

This commit is contained in:
2025-12-23 11:15:17 -05:00
parent 9b8b3e08b6
commit 343b01375d
20 changed files with 910 additions and 34 deletions

View File

@@ -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) {

View File

@@ -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<PartRoleMappingDto> getMappings(@PathVariable String platform) {
return service.getMappingsForPlatform(platform);
}
// Thin mapping for the builder
@GetMapping("/{platform}/map")
public List<PartRoleToCategoryDto> getRoleMap(@PathVariable String platform) {
return service.getRoleToCategoryMap(platform);
}
}
//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<PartRoleMappingDto> getMappings(@PathVariable String platform) {
// return service.getMappingsForPlatform(platform);
// }
//
// // Thin mapping for the builder
// @GetMapping("/{platform}/map")
// public List<PartRoleToCategoryDto> getRoleMap(@PathVariable String platform) {
// return service.getRoleToCategoryMap(platform);
// }
//}

View File

@@ -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<List<ProductEnrichment>> 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));
}
}

View File

@@ -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<Object[]> 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<CaliberRuleExtractor.Result> 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<String, Object>();
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);
}
}

View File

@@ -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<Result> 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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,7 @@
package group.goforward.battlbuilder.enrichment;
public enum EnrichmentSource {
AI,
RULES,
HUMAN
}

View File

@@ -0,0 +1,8 @@
package group.goforward.battlbuilder.enrichment;
public enum EnrichmentStatus {
PENDING_REVIEW,
APPROVED,
REJECTED,
APPLIED
}

View File

@@ -0,0 +1,11 @@
package group.goforward.battlbuilder.enrichment;
public enum EnrichmentType {
CALIBER,
CALIBER_GROUP,
BARREL_LENGTH,
GAS_SYSTEM,
HANDGUARD_LENGTH,
CONFIGURATION,
PART_ROLE
}

View File

@@ -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<String, Object> 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<String, Object> 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<String, Object> getAttributes() { return attributes; }
public void setAttributes(Map<String, Object> 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<String, Object> getMeta() { return meta; }
public void setMeta(Map<String, Object> meta) { this.meta = meta; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
}

View File

@@ -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<ProductEnrichment, Long> {
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<ProductEnrichment> findActive(Integer productId, EnrichmentType type);
List<ProductEnrichment> 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<EnrichmentQueueItem> 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
);
}

View File

@@ -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<Product> 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<Product> 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;
}
}

View File

@@ -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";
}
}

View File

@@ -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();
}

View File

@@ -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<String, Object> 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<String, Object> attributes,
BigDecimal confidence,
String rationale,
OffsetDateTime createdAt,
String productCaliber,
String productCaliberGroup
) {}

View File

@@ -99,6 +99,18 @@ public class Product {
@OneToMany(mappedBy = "product", fetch = FetchType.LAZY)
private Set<ProductOffer> 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() {

View File

@@ -248,5 +248,50 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
""")
Page<Product> 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<Product> 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<Product> 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<Product> findProductsMissingCaliberGroup(@Param("limit") int limit);
}

View File

@@ -53,4 +53,10 @@ app.publicBaseUrl=http://localhost:3000
app.authTokenPepper=9f2f3785b59eb04b49ce08b6faffc9d01a03851018f783229e829ec0d9d01349e3fd8f216c3b87ebcafbf3610f7d151ba3cd54434b907cb5a8eab6d015a826cb
# Magic Token Duration
security.jwt.access-token-days=30
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