RequirementCoverageService.java
package com.taxonomy.relations.service;
import com.taxonomy.dto.CoverageStatistics;
import com.taxonomy.dto.NodeCoverageEntry;
import com.taxonomy.relations.model.RequirementCoverage;
import com.taxonomy.relations.repository.RequirementCoverageRepository;
import com.taxonomy.catalog.repository.TaxonomyNodeRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
/**
* Service for recording and querying requirement → taxonomy-node coverage mappings.
*
* <p>This is an independent service that consumes the output of the analysis
* pipeline without modifying any existing services.
*/
@Service
public class RequirementCoverageService {
private static final int DEFAULT_MIN_SCORE = 50;
private static final int TOP_N = 10;
private final RequirementCoverageRepository coverageRepository;
private final TaxonomyNodeRepository nodeRepository;
public RequirementCoverageService(RequirementCoverageRepository coverageRepository,
TaxonomyNodeRepository nodeRepository) {
this.coverageRepository = coverageRepository;
this.nodeRepository = nodeRepository;
}
/**
* Records all node→score mappings from {@code scores} that are at or above
* {@code minScore} for the given requirement. Existing entries for the same
* (requirementId, nodeCode) pair are replaced.
*
* @param scores map of nodeCode → score (0-100)
* @param requirementId identifier for the requirement (e.g. "REQ-101")
* @param requirementText original business text
* @param minScore minimum score threshold (inclusive); defaults to 50 if ≤ 0
*/
@Transactional
public void analyzeCoverage(Map<String, Integer> scores,
String requirementId,
String requirementText,
int minScore) {
int threshold = minScore > 0 ? minScore : DEFAULT_MIN_SCORE;
Instant now = Instant.now();
// Remove stale entries for this requirement before re-recording
coverageRepository.deleteByRequirementId(requirementId);
coverageRepository.flush();
List<RequirementCoverage> entries = scores.entrySet().stream()
.filter(e -> e.getValue() != null && e.getValue() >= threshold)
.map(e -> new RequirementCoverage(requirementId, requirementText,
e.getKey(), e.getValue(), now))
.toList();
coverageRepository.saveAll(entries);
}
/**
* Returns all requirement-coverage records for a given node code.
*/
@Transactional(readOnly = true)
public List<RequirementCoverage> getCoverageForNode(String nodeCode) {
return coverageRepository.findByNodeCode(nodeCode);
}
/**
* Returns all requirement-coverage records for a given requirement ID.
*/
@Transactional(readOnly = true)
public List<RequirementCoverage> getCoverageForRequirement(String requirementId) {
return coverageRepository.findByRequirementId(requirementId);
}
/**
* Returns aggregated coverage statistics for the whole taxonomy.
*/
@Transactional(readOnly = true)
public CoverageStatistics getCoverageStatistics() {
long totalNodes = nodeRepository.count();
long coveredNodes = coverageRepository.countDistinctNodeCodeByScoreGreaterThanEqual(DEFAULT_MIN_SCORE);
long uncoveredNodes = Math.max(0, totalNodes - coveredNodes);
double coveragePct = totalNodes > 0 ? (double) coveredNodes / totalNodes * 100.0 : 0.0;
long totalRequirements = coverageRepository.countDistinctRequirementIds();
// Build nodeCode → requirementCount map
Map<String, Long> densityMap = buildDensityMap();
double avgReqPerNode = coveredNodes > 0
? densityMap.values().stream().mapToLong(Long::longValue).sum() / (double) coveredNodes
: 0.0;
List<NodeCoverageEntry> topCovered = densityMap.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue(Comparator.reverseOrder())
.thenComparing(Map.Entry.comparingByKey()))
.limit(TOP_N)
.map(e -> new NodeCoverageEntry(e.getKey(), e.getValue().intValue()))
.toList();
// Gap candidates: taxonomy nodes with zero coverage entries
List<NodeCoverageEntry> gapCandidates = nodeRepository.findAll().stream()
.filter(n -> !densityMap.containsKey(n.getCode()))
.sorted(Comparator.comparing(n -> n.getCode()))
.limit(TOP_N)
.map(n -> new NodeCoverageEntry(n.getCode(), 0))
.toList();
return new CoverageStatistics(
(int) totalNodes,
(int) coveredNodes,
(int) uncoveredNodes,
coveragePct,
avgReqPerNode,
(int) totalRequirements,
topCovered,
gapCandidates
);
}
/**
* Returns a map of {@code nodeCode → requirementCount} for heatmap visualisation.
*/
@Transactional(readOnly = true)
public Map<String, Integer> getRequirementDensityMap() {
return buildDensityMap().entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().intValue()));
}
/**
* Removes all coverage entries for the given requirement ID.
*/
@Transactional
public void deleteCoverageForRequirement(String requirementId) {
coverageRepository.deleteByRequirementId(requirementId);
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private Map<String, Long> buildDensityMap() {
return coverageRepository.findNodeCodeCountPairs().stream()
.collect(Collectors.toMap(
row -> (String) row[0],
row -> (Long) row[1]
));
}
}