HypothesisService.java
package com.taxonomy.versioning.service;
import com.taxonomy.dto.RelationHypothesisDto;
import com.taxonomy.dsl.storage.DslGitRepository;
import com.taxonomy.dsl.storage.DslGitRepositoryFactory;
import com.taxonomy.model.*;
import com.taxonomy.relations.repository.RelationEvidenceRepository;
import com.taxonomy.relations.repository.RelationHypothesisRepository;
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.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import com.taxonomy.analysis.service.AnalysisRelationGenerator;
import com.taxonomy.catalog.model.TaxonomyRelation;
import com.taxonomy.catalog.service.TaxonomyRelationService;
import com.taxonomy.model.HypothesisStatus;
import com.taxonomy.model.RelationType;
import com.taxonomy.relations.model.RelationEvidence;
import com.taxonomy.relations.model.RelationHypothesis;
import com.taxonomy.workspace.service.WorkspaceContext;
import com.taxonomy.workspace.service.WorkspaceContextResolver;
/**
* Manages the lifecycle of relation hypotheses: persistence, acceptance, and rejection.
*
* <p>Bridges the gap between:
* <ul>
* <li>{@link AnalysisRelationGenerator} which produces in-memory DTOs</li>
* <li>{@link RelationHypothesis} entities persisted in the database</li>
* <li>{@link TaxonomyRelation} entities created when hypotheses are accepted</li>
* <li>{@link DslGitRepository} for versioned DSL commits on the "draft" branch</li>
* </ul>
*
* <p>When hypotheses are persisted from analysis, a DSL representation is
* automatically generated and committed to the "draft" Git branch.
*/
@Service
public class HypothesisService {
private static final Logger log = LoggerFactory.getLogger(HypothesisService.class);
private final RelationHypothesisRepository hypothesisRepository;
private final RelationEvidenceRepository evidenceRepository;
private final TaxonomyRelationService relationService;
private final TaxonomyNodeRepository nodeRepository;
private final DslGitRepository gitRepository;
private final WorkspaceContextResolver contextResolver;
public HypothesisService(RelationHypothesisRepository hypothesisRepository,
RelationEvidenceRepository evidenceRepository,
TaxonomyRelationService relationService,
TaxonomyNodeRepository nodeRepository,
DslGitRepositoryFactory repositoryFactory,
WorkspaceContextResolver contextResolver) {
this.hypothesisRepository = hypothesisRepository;
this.evidenceRepository = evidenceRepository;
this.relationService = relationService;
this.nodeRepository = nodeRepository;
this.gitRepository = repositoryFactory.getSystemRepository();
this.contextResolver = contextResolver;
}
/**
* Persist provisional relation hypothesis DTOs (from analysis) to the database
* and commit a DSL representation to the "draft" Git branch.
*
* <p>Each DTO is saved as a {@link RelationHypothesis} entity with status
* {@link HypothesisStatus#PROVISIONAL}. Duplicates (same source+target+type
* within the same session) are skipped.
*
* <p>After persisting, a DSL document is generated from the hypotheses and
* committed to the "draft" branch in the JGit repository.
*
* @param hypotheses the provisional hypotheses from analysis
* @param sessionId unique identifier for the analysis session
* @return the persisted entities
*/
@Transactional
public List<RelationHypothesis> persistFromAnalysis(List<RelationHypothesisDto> hypotheses,
String sessionId) {
if (hypotheses == null || hypotheses.isEmpty()) {
return List.of();
}
final String effectiveSessionId = sessionId != null ? sessionId : UUID.randomUUID().toString();
List<RelationHypothesis> persisted = new ArrayList<>();
for (RelationHypothesisDto dto : hypotheses) {
// Skip duplicates in the same session
List<RelationHypothesis> existing = hypothesisRepository
.findBySourceNodeIdAndTargetNodeIdAndRelationType(
dto.getSourceCode(), dto.getTargetCode(),
RelationType.valueOf(dto.getRelationType()));
boolean alreadyExists = existing.stream()
.anyMatch(h -> effectiveSessionId.equals(h.getAnalysisSessionId()));
if (alreadyExists) {
continue;
}
RelationHypothesis entity = new RelationHypothesis();
entity.setSourceNodeId(dto.getSourceCode());
entity.setTargetNodeId(dto.getTargetCode());
entity.setRelationType(RelationType.valueOf(dto.getRelationType()));
entity.setConfidence(dto.getConfidence());
entity.setStatus(HypothesisStatus.PROVISIONAL);
entity.setAnalysisSessionId(effectiveSessionId);
WorkspaceContext ctx = contextResolver.resolveCurrentContext();
entity.setWorkspaceId(ctx.workspaceId());
entity.setOwnerUsername(ctx.username());
RelationHypothesis saved = hypothesisRepository.save(entity);
persisted.add(saved);
// Create evidence record from reasoning
if (dto.getReasoning() != null && !dto.getReasoning().isBlank()) {
RelationEvidence evidence = new RelationEvidence();
evidence.setHypothesis(saved);
evidence.setEvidenceType("analysis-rule");
evidence.setSummary(dto.getReasoning());
evidence.setConfidence(dto.getConfidence());
evidenceRepository.save(evidence);
}
}
log.info("Persisted {} hypotheses for session {}", persisted.size(), effectiveSessionId);
// Generate DSL and commit to "draft" branch
if (!persisted.isEmpty()) {
commitHypothesesAsDsl(persisted, effectiveSessionId);
}
return persisted;
}
/**
* Accept a hypothesis: creates a real {@link TaxonomyRelation} and marks the
* hypothesis as {@link HypothesisStatus#ACCEPTED}.
*
* <p>If the source or target nodes no longer exist in the taxonomy, the hypothesis
* is still marked as accepted but no relation is created.
*
* <p>After acceptance, a DSL commit is created on the "accepted" branch.
*
* @param hypothesisId the ID of the hypothesis to accept
* @return the accepted hypothesis entity
* @throws IllegalArgumentException if the hypothesis is not found
* @throws IllegalStateException if the hypothesis is not in PROVISIONAL or PROPOSED status
*/
@Transactional
public RelationHypothesis accept(Long hypothesisId) {
RelationHypothesis hypothesis = hypothesisRepository.findById(hypothesisId)
.orElseThrow(() -> new IllegalArgumentException("Hypothesis not found: " + hypothesisId));
if (hypothesis.getStatus() == HypothesisStatus.ACCEPTED) {
throw new IllegalStateException("Hypothesis " + hypothesisId + " is already ACCEPTED");
}
if (hypothesis.getStatus() == HypothesisStatus.REJECTED) {
throw new IllegalStateException("Hypothesis " + hypothesisId + " is already REJECTED");
}
// Create real TaxonomyRelation if both nodes exist
boolean relationCreated = false;
if (nodeRepository.findByCode(hypothesis.getSourceNodeId()).isPresent()
&& nodeRepository.findByCode(hypothesis.getTargetNodeId()).isPresent()) {
// Use the hypothesis's stored workspace (not the current user's context)
String workspaceId = hypothesis.getWorkspaceId();
String ownerUsername = hypothesis.getOwnerUsername();
relationService.createRelation(
hypothesis.getSourceNodeId(),
hypothesis.getTargetNodeId(),
hypothesis.getRelationType(),
"Accepted from hypothesis " + hypothesisId,
"hypothesis-accepted",
workspaceId, ownerUsername);
relationCreated = true;
} else {
log.warn("Could not create relation for hypothesis {}: source or target node not found",
hypothesisId);
}
hypothesis.setStatus(HypothesisStatus.ACCEPTED);
hypothesisRepository.save(hypothesis);
log.info("Accepted hypothesis {}: {} --[{}]--> {} (relation created: {})",
hypothesisId, hypothesis.getSourceNodeId(),
hypothesis.getRelationType(), hypothesis.getTargetNodeId(),
relationCreated);
// Commit accepted relation as DSL to "accepted" branch
commitHypothesesAsDsl(List.of(hypothesis), "accepted-" + hypothesisId);
return hypothesis;
}
/**
* Reject a hypothesis.
*
* @param hypothesisId the ID of the hypothesis to reject
* @return the rejected hypothesis entity
*/
@Transactional
public RelationHypothesis reject(Long hypothesisId) {
RelationHypothesis hypothesis = hypothesisRepository.findById(hypothesisId)
.orElseThrow(() -> new IllegalArgumentException("Hypothesis not found: " + hypothesisId));
if (hypothesis.getStatus() == HypothesisStatus.ACCEPTED) {
throw new IllegalStateException("Hypothesis " + hypothesisId + " is already ACCEPTED");
}
if (hypothesis.getStatus() == HypothesisStatus.REJECTED) {
throw new IllegalStateException("Hypothesis " + hypothesisId + " is already REJECTED");
}
hypothesis.setStatus(HypothesisStatus.REJECTED);
hypothesisRepository.save(hypothesis);
log.info("Rejected hypothesis {}: {} --[{}]--> {}",
hypothesisId, hypothesis.getSourceNodeId(),
hypothesis.getRelationType(), hypothesis.getTargetNodeId());
return hypothesis;
}
/**
* Mark a hypothesis as "applied for this session only".
*
* <p>The relationship is used in the current Architecture View and exports
* but is not permanently persisted as a {@link TaxonomyRelation}.
*/
@Transactional
public RelationHypothesis applyForSession(Long hypothesisId) {
RelationHypothesis hypothesis = hypothesisRepository.findById(hypothesisId)
.orElseThrow(() -> new IllegalArgumentException("Hypothesis not found: " + hypothesisId));
hypothesis.setAppliedInCurrentAnalysis(true);
hypothesisRepository.save(hypothesis);
log.info("Applied hypothesis {} for current session: {} --[{}]--> {}",
hypothesisId, hypothesis.getSourceNodeId(),
hypothesis.getRelationType(), hypothesis.getTargetNodeId());
return hypothesis;
}
/**
* List hypotheses by status, scoped to the current workspace.
*/
@Transactional(readOnly = true)
public List<RelationHypothesis> findByStatus(HypothesisStatus status) {
WorkspaceContext ctx = contextResolver.resolveCurrentContext();
if (ctx.workspaceId() != null) {
return hypothesisRepository.findByStatusAndWorkspace(status, ctx.workspaceId());
}
return hypothesisRepository.findByStatus(status);
}
/**
* List all hypotheses, scoped to the current workspace.
*/
@Transactional(readOnly = true)
public List<RelationHypothesis> findAll() {
WorkspaceContext ctx = contextResolver.resolveCurrentContext();
if (ctx.workspaceId() != null) {
return hypothesisRepository.findByWorkspaceIdIsNullOrWorkspaceId(ctx.workspaceId());
}
return hypothesisRepository.findAll();
}
/**
* Find evidence records for a hypothesis.
*/
@Transactional(readOnly = true)
public List<RelationEvidence> findEvidence(Long hypothesisId) {
return evidenceRepository.findByHypothesisId(hypothesisId);
}
// ── DSL generation + Git commit ─────────────────────────────────
/**
* Generate DSL text from hypotheses and commit to the appropriate Git branch.
*
* <p>Provisional hypotheses go to "draft", accepted hypotheses go to "accepted".
*/
private void commitHypothesesAsDsl(List<RelationHypothesis> hypotheses, String sessionId) {
try {
String dslText = generateDsl(hypotheses);
String branch = hypotheses.stream()
.anyMatch(h -> h.getStatus() == HypothesisStatus.ACCEPTED)
? "accepted" : "draft";
String commitId = gitRepository.commitDsl(
branch, dslText,
"hypothesis-service",
"Auto-generated from analysis session " + sessionId);
log.info("Committed {} hypotheses as DSL to branch '{}': {}",
hypotheses.size(), branch, commitId);
} catch (IOException e) {
log.warn("Failed to commit hypotheses as DSL to Git: {}", e.getMessage());
// Non-fatal: DB persistence already succeeded
}
}
/**
* Generate DSL text from a list of hypotheses.
*/
private String generateDsl(List<RelationHypothesis> hypotheses) {
StringBuilder sb = new StringBuilder();
sb.append("meta\n");
sb.append(" language \"taxdsl\"\n");
sb.append(" version \"1.0\"\n");
sb.append(" namespace \"hypothesis-auto\"\n\n");
// Track declared elements to avoid duplicates
java.util.Set<String> declaredElements = new java.util.LinkedHashSet<>();
for (RelationHypothesis h : hypotheses) {
// Element declarations for source and target (avoid duplicates)
if (declaredElements.add(h.getSourceNodeId())) {
sb.append("element ").append(h.getSourceNodeId()).append(" type Node\n");
sb.append(" title \"").append(h.getSourceNodeId()).append("\"\n\n");
}
if (declaredElements.add(h.getTargetNodeId())) {
sb.append("element ").append(h.getTargetNodeId()).append(" type Node\n");
sb.append(" title \"").append(h.getTargetNodeId()).append("\"\n\n");
}
// Relation declaration
sb.append("relation ").append(h.getSourceNodeId()).append(" ")
.append(h.getRelationType().name()).append(" ")
.append(h.getTargetNodeId()).append("\n");
sb.append(" status ").append(h.getStatus().name().toLowerCase()).append("\n");
if (h.getConfidence() > 0) {
sb.append(" confidence ").append(String.format(Locale.US, "%.2f", h.getConfidence())).append("\n");
}
sb.append("\n");
}
return sb.toString();
}
}