ProjectConflictService.java
package com.taxonomy.portfolio.service;
import com.taxonomy.portfolio.dto.PortfolioDtos.ConflictView;
import com.taxonomy.portfolio.dto.PortfolioDtos.ReviewConflictRequest;
import com.taxonomy.portfolio.model.ArchitectureProject;
import com.taxonomy.portfolio.model.PortfolioTypes.ConflictStatus;
import com.taxonomy.portfolio.model.PortfolioTypes.ConflictType;
import com.taxonomy.portfolio.model.ProjectConflict;
import com.taxonomy.portfolio.model.ProjectRequirement;
import com.taxonomy.portfolio.model.ProjectRequirementVersion;
import com.taxonomy.portfolio.repository.ProjectConflictRepository;
import com.taxonomy.portfolio.repository.ProjectRequirementRepository;
import com.taxonomy.workspace.service.WorkspaceContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
/** Deterministic conflict hypotheses that always require human confirmation. */
@Service
public class ProjectConflictService {
private record ConflictRule(
ConflictType type,
String title,
Pattern requiresPattern,
Pattern forbidsPattern,
double confidence) {
}
private static final List<ConflictRule> RULES = List.of(
rule(ConflictType.HOSTING,
"Cloud-/Hosting-Vorgaben widersprechen sich",
"\\b(public cloud|cloud[- ]pflicht|cloud required|must use cloud|saas required|externes hosting erforderlich)\\b",
"\\b(no cloud|keine cloud|cloud verboten|on[- ]premises only|only on[- ]premises|nur on[- ]prem|external hosting prohibited|external hosting (?:is )?not allowed|must not use external hosting|must remain on[- ]premises|externes hosting verboten|externes hosting (?:ist )?nicht erlaubt|darf kein externes hosting|muss on[- ]premises bleiben)\\b",
0.88),
rule(ConflictType.DATA_LOCATION,
"Vorgaben zum Speicherort widersprechen sich",
"\\b(global storage|worldwide replication|außerhalb (der )?eu|outside (the )?eu|cross[- ]border storage)\\b",
"\\b(nur in deutschland|ausschließlich in deutschland|eu only|only in the eu|daten dürfen die eu nicht verlassen|must not leave the eu)\\b",
0.86),
rule(ConflictType.LIFECYCLE,
"Lebenszyklusentscheidungen widersprechen sich",
"\\b(expand|extend|modernize|retain|ausbauen|erweitern|modernisieren|beibehalten)\\b",
"\\b(retire|replace|decommission|remove|ablösen|ersetzen|stilllegen|abschaffen)\\b",
0.66),
rule(ConflictType.AVAILABILITY,
"Betriebs-/Verfügbarkeitsvorgaben widersprechen sich",
"\\b(always online|permanent connectivity|ständig online|dauerhafte verbindung|online only)\\b",
"\\b(offline capable|offline first|ohne netz|offline[- ]fähig|air[- ]gapped)\\b",
0.72),
rule(ConflictType.PLATFORM,
"Plattformvorgaben widersprechen sich",
"\\b(vendor[- ]specific|proprietary only|herstellerspezifisch|nur proprietär)\\b",
"\\b(vendor neutral|open standards only|herstellerneutral|nur offene standards)\\b",
0.70)
);
private final ProjectConflictRepository conflictRepository;
private final ProjectRequirementRepository requirementRepository;
private final ProjectPortfolioService projectService;
private final PortfolioFingerprintService fingerprintService;
public ProjectConflictService(ProjectConflictRepository conflictRepository,
ProjectRequirementRepository requirementRepository,
ProjectPortfolioService projectService,
PortfolioFingerprintService fingerprintService) {
this.conflictRepository = conflictRepository;
this.requirementRepository = requirementRepository;
this.projectService = projectService;
this.fingerprintService = fingerprintService;
}
@Transactional
public List<ConflictView> detect(Long projectId,
String username,
WorkspaceContext context) {
ArchitectureProject project = projectService.requireProject(projectId, username, context);
List<ProjectRequirement> requirements = requirementRepository
.findByProjectIdAndScopeKeyOrderByRequirementKeyAsc(
projectId, project.getScopeKey());
Instant now = Instant.now();
for (int leftIndex = 0; leftIndex < requirements.size(); leftIndex++) {
ProjectRequirement left = requirements.get(leftIndex);
ProjectRequirementVersion leftVersion = projectService.currentVersion(left);
String leftText = normalize(leftVersion.getText());
for (int rightIndex = leftIndex + 1; rightIndex < requirements.size(); rightIndex++) {
ProjectRequirement right = requirements.get(rightIndex);
ProjectRequirementVersion rightVersion = projectService.currentVersion(right);
String rightText = normalize(rightVersion.getText());
for (ConflictRule rule : RULES) {
boolean leftRequiresRightForbids = rule.requiresPattern().matcher(leftText).find()
&& rule.forbidsPattern().matcher(rightText).find();
boolean rightRequiresLeftForbids = rule.requiresPattern().matcher(rightText).find()
&& rule.forbidsPattern().matcher(leftText).find();
if (!leftRequiresRightForbids && !rightRequiresLeftForbids) continue;
String evidence = "Potential conflict between " + left.getRequirementKey()
+ " (version " + leftVersion.getVersionNumber() + ") and "
+ right.getRequirementKey() + " (version " + rightVersion.getVersionNumber()
+ "). One text matches the positive constraint and the other the prohibiting constraint."
+ " Human review is required.";
String fingerprint = fingerprintService.contentFingerprint(
projectId + "|" + left.getId() + "|" + right.getId() + "|"
+ rule.type() + "|" + leftVersion.getContentHash() + "|"
+ rightVersion.getContentHash());
if (conflictRepository.findByProjectIdAndFingerprint(projectId, fingerprint).isEmpty()) {
conflictRepository.save(new ProjectConflict(
project,
left,
right,
rule.type(),
fingerprint,
rule.title(),
evidence,
rule.confidence(),
now));
}
}
}
}
return list(projectId, username, context);
}
@Transactional(readOnly = true)
public List<ConflictView> list(Long projectId,
String username,
WorkspaceContext context) {
projectService.requireProject(projectId, username, context);
return conflictRepository.findByProjectIdOrderByConfidenceDescDetectedAtDesc(projectId)
.stream().map(this::toView).toList();
}
@Transactional
public ConflictView review(Long projectId,
Long conflictId,
ReviewConflictRequest request,
String username,
WorkspaceContext context) {
projectService.requireProject(projectId, username, context);
if (request == null || request.status() == null) {
throw PortfolioException.validation("conflict review status is required");
}
if (request.status() == ConflictStatus.PROPOSED) {
throw PortfolioException.validation("A review cannot reset a conflict to PROPOSED");
}
ProjectConflict conflict = conflictRepository.findByIdAndProjectId(conflictId, projectId)
.orElseThrow(() -> PortfolioException.notFound("Project conflict not found: " + conflictId));
conflict.review(
request.status(),
ProjectPortfolioService.limited(request.resolutionNote(), 4000, "resolutionNote"),
PortfolioScope.username(username, context),
Instant.now());
return toView(conflict);
}
public ConflictView toView(ProjectConflict conflict) {
return new ConflictView(
conflict.getId(),
conflict.getProject().getId(),
conflict.getRequirementA().getId(),
conflict.getRequirementA().getRequirementKey(),
conflict.getRequirementB().getId(),
conflict.getRequirementB().getRequirementKey(),
conflict.getConflictType(),
conflict.getStatus(),
conflict.getTitle(),
conflict.getEvidence(),
conflict.getConfidence(),
conflict.getResolutionNote(),
conflict.getDetectedAt(),
conflict.getReviewedBy(),
conflict.getReviewedAt());
}
private static ConflictRule rule(ConflictType type,
String title,
String positive,
String negative,
double confidence) {
int flags = Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE;
return new ConflictRule(
type,
title,
Pattern.compile(positive, flags),
Pattern.compile(negative, flags),
confidence);
}
private static String normalize(String value) {
return value == null ? "" : value.toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
}
}