CopilotCompletionService.java
package com.taxonomy.portfolio.service;
import com.taxonomy.portfolio.dto.PortfolioDtos.ProductView;
import com.taxonomy.portfolio.dto.PortfolioDtos.ProjectSolutionView;
import com.taxonomy.portfolio.dto.PortfolioDtos.SolutionProductCandidateView;
import com.taxonomy.portfolio.dto.PortfolioDtos.TaxonomyCoverageView;
import com.taxonomy.portfolio.dto.PortfolioDtos.UpsertProductCandidateRequest;
import com.taxonomy.portfolio.model.PortfolioTypes.ProductSelectionStatus;
import com.taxonomy.portfolio.model.PortfolioTypes.ProductStatus;
import com.taxonomy.portfolio.model.PortfolioTypes.ReviewStatus;
import com.taxonomy.workspace.service.WorkspaceContext;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Deterministic post-analysis enrichment. It creates proposals only: no solution
* or product is selected, no procurement is authorized, and no review is confirmed.
*/
@Service
public class CopilotCompletionService {
private final SolutionPortfolioService solutionService;
private final ProductCatalogService productService;
private final int minimumCoverage;
private final double minimumConfidence;
public CopilotCompletionService(
SolutionPortfolioService solutionService,
ProductCatalogService productService,
@Value("${taxonomy.ai.product-proposals.minimum-coverage:25}") int minimumCoverage,
@Value("${taxonomy.ai.product-proposals.minimum-confidence:0.25}")
double minimumConfidence) {
this.solutionService = solutionService;
this.productService = productService;
this.minimumCoverage = Math.max(0, Math.min(100, minimumCoverage));
this.minimumConfidence = Math.max(0.0, Math.min(1.0, minimumConfidence));
}
public CompletionSummary enrich(
Long projectId,
String username,
WorkspaceContext context,
boolean proposeSolutions,
boolean proposeProducts) {
List<ProjectSolutionView> projectSolutions = proposeSolutions
? solutionService.proposeFromCurrentMappings(projectId, username, context)
: solutionService.listProjectSolutions(projectId, username, context);
if (proposeSolutions) {
// Include pre-existing project solutions as well as newly proposed ones.
projectSolutions = solutionService.listProjectSolutions(projectId, username, context);
}
int productsCreated = proposeProducts
? proposeProducts(projectId, projectSolutions, username, context)
: 0;
return new CompletionSummary(
projectSolutions.size(),
productsCreated,
"Every generated solution and product remains PROPOSED/CANDIDATE until human review.");
}
private int proposeProducts(
Long projectId,
List<ProjectSolutionView> projectSolutions,
String username,
WorkspaceContext context) {
List<ProductView> products = productService.listProducts(username, context);
int created = 0;
for (ProjectSolutionView projectSolution : projectSolutions) {
Map<String, Integer> solutionCoverage = confirmedCoverage(
projectSolution.solution().taxonomyCoverage());
if (solutionCoverage.isEmpty()) continue;
Set<Long> existingProducts = projectSolution.productCandidates().stream()
.map(SolutionProductCandidateView::product)
.map(ProductView::id)
.collect(java.util.stream.Collectors.toCollection(HashSet::new));
for (ProductView product : products) {
if (existingProducts.contains(product.id()) || unsupported(product.productStatus())) {
continue;
}
Map<String, Integer> productCoverage = confirmedCoverage(product.taxonomyCoverage());
Set<String> intersection = new LinkedHashSet<>(solutionCoverage.keySet());
intersection.retainAll(productCoverage.keySet());
if (intersection.isEmpty()) continue;
int coverage = (int) Math.round(intersection.stream()
.mapToInt(code -> Math.min(
solutionCoverage.get(code), productCoverage.get(code)))
.average()
.orElse(0.0));
double confidence = intersection.size()
/ (double) Math.max(1, solutionCoverage.size());
if (coverage < minimumCoverage || confidence < minimumConfidence) {
continue;
}
List<String> nodes = new ArrayList<>(intersection);
nodes.sort(String::compareTo);
String joinedNodes = String.join(", ", nodes);
String strengths = "Confirmed catalogue coverage overlaps on " + joinedNodes;
String openEvidence = "Verify product edition, support status, deployment fit, "
+ "security/compliance claims and current source evidence before selection. "
+ "Catalogue source: " + product.sourceReference();
productService.upsertCandidate(
projectId,
projectSolution.id(),
new UpsertProductCandidateRequest(
product.id(),
coverage,
null,
strengths,
null,
openEvidence,
confidence,
ReviewStatus.PROPOSED,
ProductSelectionStatus.CANDIDATE),
username,
context);
existingProducts.add(product.id());
created++;
}
}
return created;
}
private static Map<String, Integer> confirmedCoverage(
List<TaxonomyCoverageView> coverage) {
Map<String, Integer> result = new HashMap<>();
if (coverage == null) return result;
for (TaxonomyCoverageView mapping : coverage) {
if (mapping != null
&& mapping.reviewStatus() == ReviewStatus.CONFIRMED
&& mapping.nodeCode() != null
&& !mapping.nodeCode().isBlank()) {
result.merge(mapping.nodeCode(), mapping.coveragePercent(), Math::max);
}
}
return result;
}
private static boolean unsupported(ProductStatus status) {
return status == ProductStatus.END_OF_SUPPORT
|| status == ProductStatus.WITHDRAWN;
}
public record CompletionSummary(
int projectSolutionCount,
int productCandidatesCreated,
String reviewBoundary) {
}
}