mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -05:00
working caliber mapping with ai enrichment
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
// }
|
||||
//}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package group.goforward.battlbuilder.enrichment;
|
||||
|
||||
public enum EnrichmentSource {
|
||||
AI,
|
||||
RULES,
|
||||
HUMAN
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package group.goforward.battlbuilder.enrichment;
|
||||
|
||||
public enum EnrichmentStatus {
|
||||
PENDING_REVIEW,
|
||||
APPROVED,
|
||||
REJECTED,
|
||||
APPLIED
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package group.goforward.battlbuilder.enrichment;
|
||||
|
||||
public enum EnrichmentType {
|
||||
CALIBER,
|
||||
CALIBER_GROUP,
|
||||
BARREL_LENGTH,
|
||||
GAS_SYSTEM,
|
||||
HANDGUARD_LENGTH,
|
||||
CONFIGURATION,
|
||||
PART_ROLE
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
) {}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user