ArchitectureGapService.java
package com.taxonomy.architecture.service;
import com.taxonomy.dto.*;
import com.taxonomy.model.RelationType;
import com.taxonomy.catalog.model.TaxonomyNode;
import com.taxonomy.catalog.model.TaxonomyRelation;
import com.taxonomy.catalog.repository.TaxonomyNodeRepository;
import com.taxonomy.catalog.repository.TaxonomyRelationRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
import com.taxonomy.dto.ApqcCoverageResult;
import com.taxonomy.dto.CoverageGap;
import com.taxonomy.dto.GapAnalysisView;
import com.taxonomy.dto.IncompletePattern;
import com.taxonomy.dto.MissingRelation;
import com.taxonomy.relations.service.RelationCompatibilityMatrix;
/**
* Analyses architectural gaps by comparing expected relations (from the
* {@link RelationCompatibilityMatrix}) with actual relations in the repository.
*
* <p>For each anchor node (high-scoring node from a requirement analysis),
* this service checks which relation types <em>should</em> exist according to
* the compatibility matrix and reports any that are missing.
*/
@Service
public class ArchitectureGapService {
private static final Logger log = LoggerFactory.getLogger(ArchitectureGapService.class);
private static final int DEFAULT_MIN_SCORE = 50;
private final RelationCompatibilityMatrix compatibilityMatrix;
private final TaxonomyNodeRepository nodeRepository;
private final TaxonomyRelationRepository relationRepository;
public ArchitectureGapService(RelationCompatibilityMatrix compatibilityMatrix,
TaxonomyNodeRepository nodeRepository,
TaxonomyRelationRepository relationRepository) {
this.compatibilityMatrix = compatibilityMatrix;
this.nodeRepository = nodeRepository;
this.relationRepository = relationRepository;
}
/**
* Performs a gap analysis for the given scores and business text.
*
* @param scores map of nodeCode → score (0–100)
* @param businessText the original business requirement text
* @param minScore minimum score threshold; 0 means default (50)
* @return gap analysis view with missing relations, incomplete patterns, and coverage gaps
*/
@Transactional(readOnly = true)
public GapAnalysisView analyze(Map<String, Integer> scores, String businessText, int minScore) {
GapAnalysisView view = new GapAnalysisView();
view.setBusinessText(businessText);
if (scores == null || scores.isEmpty()) {
view.getNotes().add("No scores provided; gap analysis cannot be performed.");
return view;
}
int threshold = minScore > 0 ? minScore : DEFAULT_MIN_SCORE;
// Filter to anchor nodes above threshold
Map<String, Integer> anchors = scores.entrySet().stream()
.filter(e -> e.getValue() != null && e.getValue() >= threshold)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
view.setTotalAnchors(anchors.size());
if (anchors.isEmpty()) {
view.getNotes().add("No nodes above the score threshold of " + threshold + ".");
return view;
}
List<MissingRelation> missingRelations = new ArrayList<>();
List<IncompletePattern> incompletePatterns = new ArrayList<>();
List<CoverageGap> coverageGaps = new ArrayList<>();
for (Map.Entry<String, Integer> entry : anchors.entrySet()) {
String nodeCode = entry.getKey();
int score = entry.getValue();
Optional<TaxonomyNode> nodeOpt = nodeRepository.findByCode(nodeCode);
if (nodeOpt.isEmpty()) {
continue;
}
TaxonomyNode node = nodeOpt.get();
String sourceRoot = node.getTaxonomyRoot();
if (sourceRoot == null || sourceRoot.isBlank()) {
continue;
}
// Get expected outgoing relation types for this taxonomy root
Map<RelationType, Set<String>> expected =
compatibilityMatrix.getExpectedOutgoingRelations(sourceRoot);
// Get actual outgoing relations for this specific node
List<TaxonomyRelation> actualOutgoing = relationRepository.findBySourceNodeCode(nodeCode);
Set<String> actualRelTypeNames = actualOutgoing.stream()
.map(r -> r.getRelationType().name())
.collect(Collectors.toSet());
// Build set of actual target roots per relation type
Map<String, Set<String>> actualTargetRootsByType = new HashMap<>();
for (TaxonomyRelation rel : actualOutgoing) {
String targetRoot = rel.getTargetNode() != null
? rel.getTargetNode().getTaxonomyRoot() : null;
if (targetRoot != null) {
actualTargetRootsByType
.computeIfAbsent(rel.getRelationType().name(), k -> new HashSet<>())
.add(targetRoot);
}
}
boolean hasAnyGap = false;
for (Map.Entry<RelationType, Set<String>> expectedEntry : expected.entrySet()) {
RelationType relType = expectedEntry.getKey();
Set<String> expectedTargets = expectedEntry.getValue();
if (!actualRelTypeNames.contains(relType.name())) {
// No relation of this type exists at all
for (String targetRoot : expectedTargets) {
missingRelations.add(new MissingRelation(
nodeCode, sourceRoot, relType.name(), targetRoot,
nodeCode + " (" + sourceRoot + ") has no "
+ relType.name() + " relation to any " + targetRoot + " node"));
}
incompletePatterns.add(new IncompletePattern(
nodeCode, sourceRoot,
sourceRoot + " → " + relType.name() + " → "
+ String.join("/", expectedTargets),
"No " + relType.name() + " relation exists"));
hasAnyGap = true;
} else {
// Relation type exists — check if all expected target roots are covered
Set<String> actualRoots = actualTargetRootsByType
.getOrDefault(relType.name(), Set.of());
for (String targetRoot : expectedTargets) {
if (!actualRoots.contains(targetRoot)) {
missingRelations.add(new MissingRelation(
nodeCode, sourceRoot, relType.name(), targetRoot,
nodeCode + " has " + relType.name()
+ " but not to a " + targetRoot + " node"));
hasAnyGap = true;
}
}
}
}
if (hasAnyGap) {
coverageGaps.add(new CoverageGap(
nodeCode, sourceRoot, score,
"Node has coverage (score " + score
+ ") but is missing expected architectural relations"));
}
}
view.setMissingRelations(missingRelations);
view.setIncompletePatterns(incompletePatterns);
view.setCoverageGaps(coverageGaps);
view.setTotalGaps(missingRelations.size());
log.info("Gap analysis: {} anchors, {} missing relations, {} incomplete patterns, {} coverage gaps",
anchors.size(), missingRelations.size(), incompletePatterns.size(), coverageGaps.size());
return view;
}
/**
* Analyses which APQC process categories are covered by the current architecture model.
*
* <p>Searches for nodes whose code patterns suggest APQC provenance (imported
* via the APQC pipeline) and checks whether they have outgoing relations,
* indicating integration with the broader architecture.
*
* @param requirementText optional business requirement text for context
* @return APQC coverage result with per-level statistics
*/
@Transactional(readOnly = true)
public ApqcCoverageResult analyzeApqcCoverage(String requirementText) {
// Find all relations to check for APQC-sourced nodes
List<TaxonomyRelation> allRelations = relationRepository.findAll();
// Check relations for APQC provenance
List<TaxonomyRelation> apqcRelations = allRelations.stream()
.filter(r -> "APQC_IMPORT".equals(r.getProvenance()) ||
"dsl-materialize".equals(r.getProvenance()) ||
(r.getDescription() != null && r.getDescription().toLowerCase().contains("apqc")))
.toList();
// Identify unique APQC-related node codes from source and target
Set<String> apqcNodeCodes = apqcRelations.stream()
.flatMap(rel -> java.util.stream.Stream.of(rel.getSourceNode(), rel.getTargetNode()))
.filter(Objects::nonNull)
.map(TaxonomyNode::getCode)
.collect(Collectors.toCollection(LinkedHashSet::new));
// Group by taxonomy root (which maps to APQC levels via the import profile)
Map<String, Integer> coverageByLevel = new LinkedHashMap<>();
// APQC mapping: CP=Category, BP=ProcessGroup, CR=Process, CI=Activity, BR=Task
Map<String, String> rootToLevel = Map.of(
"CP", "Category", "BP", "ProcessGroup", "CR", "Process",
"CI", "Activity", "BR", "Task");
for (String code : apqcNodeCodes) {
Optional<TaxonomyNode> nodeOpt = nodeRepository.findByCode(code);
nodeOpt.ifPresent(node -> {
String level = rootToLevel.getOrDefault(node.getTaxonomyRoot(), "Unknown");
coverageByLevel.merge(level, 1, Integer::sum);
});
}
// Count categories that have at least one relation (covered)
int totalCategories = coverageByLevel.values().stream().mapToInt(Integer::intValue).sum();
Set<String> coveredRoots = new LinkedHashSet<>();
for (TaxonomyRelation rel : apqcRelations) {
if (rel.getSourceNode() != null) coveredRoots.add(rel.getSourceNode().getTaxonomyRoot());
if (rel.getTargetNode() != null) coveredRoots.add(rel.getTargetNode().getTaxonomyRoot());
}
// Count categories with outgoing relations as "covered"
int coveredCategories = 0;
List<String> uncoveredCategories = new ArrayList<>();
for (String levelName : rootToLevel.values()) {
int count = coverageByLevel.getOrDefault(levelName, 0);
if (count > 0) {
coveredCategories++;
} else {
uncoveredCategories.add(levelName);
}
}
double coveragePercent = rootToLevel.size() > 0
? (coveredCategories * 100.0) / rootToLevel.size()
: 0.0;
log.info("APQC coverage analysis: {} categories, {} covered, {}%",
totalCategories, coveredCategories, String.format("%.1f", coveragePercent));
return new ApqcCoverageResult(
totalCategories,
coveredCategories,
coveragePercent,
uncoveredCategories,
coverageByLevel);
}
}