ArchitectureRecommendationService.java
package com.taxonomy.architecture.service;
import com.taxonomy.dto.*;
import com.taxonomy.catalog.model.TaxonomyNode;
import com.taxonomy.catalog.repository.TaxonomyNodeRepository;
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.ArchitectureRecommendation;
import com.taxonomy.dto.GapAnalysisView;
import com.taxonomy.dto.MissingRelation;
import com.taxonomy.dto.RecommendedElement;
import com.taxonomy.dto.SuggestedRelation;
import com.taxonomy.shared.service.LocalEmbeddingService;
/**
* Combines requirement scoring, gap analysis, and semantic search
* to produce architecture recommendations for a business requirement.
*
* <p>Pipeline:
* <ol>
* <li>Identify confirmed elements from high scores</li>
* <li>Run gap analysis to find missing architectural links</li>
* <li>For each gap, propose candidate nodes from the missing taxonomy root</li>
* <li>Suggest relations to fill the gaps</li>
* </ol>
*/
@Service
public class ArchitectureRecommendationService {
private static final Logger log = LoggerFactory.getLogger(ArchitectureRecommendationService.class);
private static final int HIGH_SCORE_THRESHOLD = 70;
private static final int DEFAULT_MIN_SCORE = 50;
private static final int MAX_PROPOSALS_PER_GAP = 3;
private final ArchitectureGapService gapService;
private final TaxonomyNodeRepository nodeRepository;
private final LocalEmbeddingService embeddingService;
public ArchitectureRecommendationService(ArchitectureGapService gapService,
TaxonomyNodeRepository nodeRepository,
LocalEmbeddingService embeddingService) {
this.gapService = gapService;
this.nodeRepository = nodeRepository;
this.embeddingService = embeddingService;
}
/**
* Produces architecture recommendations for a business requirement.
*
* @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 architecture recommendation with confirmed, proposed elements, and suggested relations
*/
@Transactional(readOnly = true)
public ArchitectureRecommendation recommend(Map<String, Integer> scores,
String businessText, int minScore) {
ArchitectureRecommendation rec = new ArchitectureRecommendation();
rec.setBusinessText(businessText);
if (scores == null || scores.isEmpty()) {
rec.getNotes().add("No scores provided; recommendations cannot be generated.");
return rec;
}
int threshold = minScore > 0 ? minScore : DEFAULT_MIN_SCORE;
// Step 1: Identify confirmed elements (high score)
List<RecommendedElement> confirmed = new ArrayList<>();
for (Map.Entry<String, Integer> entry : scores.entrySet()) {
if (entry.getValue() != null && entry.getValue() >= HIGH_SCORE_THRESHOLD) {
Optional<TaxonomyNode> nodeOpt = nodeRepository.findByCode(entry.getKey());
if (nodeOpt.isPresent()) {
TaxonomyNode node = nodeOpt.get();
confirmed.add(new RecommendedElement(
node.getCode(),
node.getNameEn(),
node.getTaxonomyRoot(),
entry.getValue(),
"High-confidence match (score " + entry.getValue() + ")"));
}
}
}
rec.setConfirmedElements(confirmed);
rec.getReasoning().add("Identified " + confirmed.size()
+ " confirmed elements with score >= " + HIGH_SCORE_THRESHOLD);
// Step 2: Run gap analysis
GapAnalysisView gaps = gapService.analyze(scores, businessText, threshold);
rec.getReasoning().add("Gap analysis found " + gaps.getMissingRelations().size()
+ " missing relations");
// Step 3: For each missing relation, propose candidates
List<RecommendedElement> proposed = new ArrayList<>();
List<SuggestedRelation> suggestedRelations = new ArrayList<>();
Set<String> proposedCodes = new HashSet<>();
for (MissingRelation missing : gaps.getMissingRelations()) {
String targetRoot = missing.getExpectedTargetRoot();
String sourceCode = missing.getSourceNodeCode();
// Find candidate nodes from the target taxonomy root
List<TaxonomyNode> candidates = nodeRepository.findByTaxonomyRootOrderByLevelAscNameEnAsc(targetRoot);
// If embedding service is available, score candidates semantically
List<TaxonomyNode> ranked;
if (embeddingService.isAvailable() && businessText != null && !businessText.isBlank()) {
ranked = rankByEmbeddingSimilarity(candidates, businessText);
} else {
// Fallback: use first N candidates sorted by level (leaf nodes are more specific)
ranked = candidates.stream()
.sorted(Comparator.comparingInt(TaxonomyNode::getLevel).reversed())
.limit(MAX_PROPOSALS_PER_GAP)
.collect(Collectors.toList());
}
int added = 0;
for (TaxonomyNode candidate : ranked) {
if (added >= MAX_PROPOSALS_PER_GAP) break;
if (proposedCodes.contains(candidate.getCode())) continue;
proposedCodes.add(candidate.getCode());
proposed.add(new RecommendedElement(
candidate.getCode(),
candidate.getNameEn(),
candidate.getTaxonomyRoot(),
0, // No score — this is a proposal
"Proposed to fill gap: " + missing.getDescription()));
suggestedRelations.add(new SuggestedRelation(
sourceCode,
candidate.getCode(),
missing.getExpectedRelationType(),
"Would complete " + missing.getSourceRoot() + " → "
+ missing.getExpectedRelationType() + " → " + targetRoot));
added++;
}
}
rec.setProposedElements(proposed);
rec.setSuggestedRelations(suggestedRelations);
rec.getReasoning().add("Proposed " + proposed.size() + " elements and "
+ suggestedRelations.size() + " relations to fill gaps");
// Step 4: Compute confidence
int totalExpected = confirmed.size() + gaps.getMissingRelations().size();
double confidence = totalExpected > 0
? (double) confirmed.size() / totalExpected * 100.0
: 0.0;
rec.setConfidence(Math.round(confidence * 100.0) / 100.0);
rec.getReasoning().add("Overall confidence: " + rec.getConfidence()
+ "% (" + confirmed.size() + " confirmed / " + totalExpected + " expected)");
log.info("Recommendation: {} confirmed, {} proposed, {} suggested relations, confidence={}%",
confirmed.size(), proposed.size(), suggestedRelations.size(), rec.getConfidence());
return rec;
}
/**
* Ranks candidate nodes by semantic similarity to the business text
* using the local embedding service.
*/
private List<TaxonomyNode> rankByEmbeddingSimilarity(List<TaxonomyNode> candidates,
String businessText) {
try {
Map<String, Integer> candidateScores = embeddingService.scoreNodes(
businessText, candidates);
return candidates.stream()
.sorted(Comparator.comparingInt(
(TaxonomyNode n) -> candidateScores.getOrDefault(n.getCode(), 0)).reversed())
.limit(MAX_PROPOSALS_PER_GAP)
.collect(Collectors.toList());
} catch (Exception e) {
log.warn("Embedding ranking failed, using fallback: {}", e.getMessage());
return candidates.stream()
.sorted(Comparator.comparingInt(TaxonomyNode::getLevel).reversed())
.limit(MAX_PROPOSALS_PER_GAP)
.collect(Collectors.toList());
}
}
}