RelationProposalService.java
package com.taxonomy.relations.service;
import com.taxonomy.catalog.model.TaxonomyNode;
import com.taxonomy.catalog.repository.TaxonomyNodeRepository;
import com.taxonomy.dto.RelationProposalDto;
import com.taxonomy.dto.TaxonomyNodeDto;
import com.taxonomy.model.ProposalStatus;
import com.taxonomy.model.RelationType;
import com.taxonomy.relations.model.RelationProposal;
import com.taxonomy.relations.repository.RelationProposalRepository;
import com.taxonomy.relations.service.RelationProjectionReadService.IdentitySnapshot;
import com.taxonomy.workspace.service.RepositoryContext;
import com.taxonomy.workspace.service.RepositoryScope;
import com.taxonomy.workspace.service.WorkspaceResolver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
/**
* Orchestrator for the Relation Proposal Pipeline.
*
* <p>Every persisted/read proposal is scoped by an explicit
* {@link RepositoryContext}; a null workspace means central state only inside
* that repository and never a global proposal set.</p>
*
* <p>Pipeline stages:
* <ol>
* <li>Resolve one complete active-relation identity snapshot</li>
* <li>Candidate Search (via {@link RelationCandidateService})</li>
* <li>RelationType Compatibility filtering</li>
* <li>Validation (via {@link RelationValidationService})</li>
* <li>Confidence scoring</li>
* <li>Proposal creation and tenant-scoped persistence</li>
* </ol>
*/
@Service
public class RelationProposalService {
private static final Logger log = LoggerFactory.getLogger(
RelationProposalService.class);
private final TaxonomyNodeRepository nodeRepository;
private final RelationProposalRepository proposalRepository;
private final RelationCandidateService candidateService;
private final RelationProjectionReadService relationReadService;
private final RelationValidationService validationService;
private final WorkspaceResolver workspaceResolver;
public RelationProposalService(
TaxonomyNodeRepository nodeRepository,
RelationProposalRepository proposalRepository,
RelationCandidateService candidateService,
RelationProjectionReadService relationReadService,
RelationValidationService validationService,
WorkspaceResolver workspaceResolver) {
this.nodeRepository = nodeRepository;
this.proposalRepository = proposalRepository;
this.candidateService = candidateService;
this.relationReadService = relationReadService;
this.validationService = validationService;
this.workspaceResolver = workspaceResolver;
}
/**
* Resolve one request-stable context and run the proposal pipeline.
*
* <p>This compatibility boundary deliberately fails for a central read-only
* context. HTTP boundaries that authorize central mutation must convert it to
* {@link RepositoryScope#CENTRAL_WRITE} explicitly before calling the
* context-aware method.</p>
*/
@Transactional
public List<RelationProposalDto> proposeRelations(
String sourceNodeCode,
RelationType relationType,
int limit) {
return proposeRelationsInContext(
sourceNodeCode,
relationType,
limit,
workspaceResolver.resolveCurrentRepositoryContext());
}
/** Runs the full proposal pipeline in one explicit writable tenant. */
@Transactional
public List<RelationProposalDto> proposeRelationsInContext(
String sourceNodeCode,
RelationType relationType,
int limit,
RepositoryContext context) {
RepositoryContext tenant = requireWritableContext(context);
TaxonomyNode source = nodeRepository.findByCode(sourceNodeCode)
.orElseThrow(() -> new IllegalArgumentException(
"Source node not found: " + sourceNodeCode));
log.info("Proposing {} relations for node '{}' "
+ "(repository={}, workspace={}, branch={})",
relationType,
sourceNodeCode,
tenant.repositoryId(),
tenant.workspaceId(),
tenant.branch());
IdentitySnapshot existingRelations =
relationReadService.readIdentitySnapshot(tenant);
List<TaxonomyNodeDto> candidates = candidateService.findCandidates(
source, relationType, limit);
List<RelationProposalDto> proposals = new ArrayList<>();
for (int i = 0; i < candidates.size(); i++) {
TaxonomyNodeDto candidate = candidates.get(i);
if (proposalRepository.existsInRepositoryWorkspace(
tenant.repositoryId(),
sourceNodeCode,
candidate.getCode(),
relationType,
tenant.workspaceId())) {
log.debug("Proposal already exists: {} → {} [{}] "
+ "(repository={}, workspace={})",
sourceNodeCode,
candidate.getCode(),
relationType,
tenant.repositoryId(),
tenant.workspaceId());
continue;
}
if (existingRelations.contains(
sourceNodeCode,
relationType,
candidate.getCode())) {
log.debug("Active relation already exists: {} → {} [{}] "
+ "(repository={}, workspace={}, branch={})",
sourceNodeCode,
candidate.getCode(),
relationType,
tenant.repositoryId(),
tenant.workspaceId(),
tenant.branch());
continue;
}
RelationValidationService.ValidationResult result =
validationService.validate(
source,
candidate,
relationType,
i,
candidates.size(),
tenant);
if (!result.isValid()) {
continue;
}
TaxonomyNode target = nodeRepository.findByCode(candidate.getCode())
.orElse(null);
if (target == null) {
continue;
}
RelationProposal proposal = new RelationProposal();
proposal.setRepositoryId(tenant.repositoryId());
proposal.setSourceNode(source);
proposal.setTargetNode(target);
proposal.setRelationType(relationType);
proposal.setConfidence(result.getConfidence());
proposal.setRationale(result.getRationale());
proposal.setProvenance("hybrid-search");
proposal.setStatus(ProposalStatus.PENDING);
proposal.setWorkspaceId(tenant.workspaceId());
proposal.setOwnerUsername(tenant.username());
RelationProposal saved = proposalRepository.save(proposal);
proposals.add(toDto(saved));
log.debug("Created proposal: {} → {} [{}] confidence={} "
+ "(repository={}, workspace={})",
sourceNodeCode,
candidate.getCode(),
relationType,
result.getConfidence(),
tenant.repositoryId(),
tenant.workspaceId());
}
log.info("Proposed {} relations for node '{}' [{}] "
+ "(repository={}, workspace={}, branch={}, relationCommit={})",
proposals.size(),
sourceNodeCode,
relationType,
tenant.repositoryId(),
tenant.workspaceId(),
tenant.branch(),
existingRelations.authoritativeCommitId());
return proposals;
}
@Transactional(readOnly = true)
public List<RelationProposalDto> getPendingProposals() {
return getPendingProposalsInContext(
workspaceResolver.resolveCurrentRepositoryContext());
}
@Transactional(readOnly = true)
public List<RelationProposalDto> getPendingProposalsInContext(
RepositoryContext context) {
RepositoryContext tenant = requireContext(context);
List<RelationProposal> proposals = tenant.workspaceId() == null
? proposalRepository.findCentralByRepositoryAndStatus(
tenant.repositoryId(), ProposalStatus.PENDING)
: proposalRepository.findVisibleByRepositoryAndWorkspaceAndStatus(
tenant.repositoryId(),
tenant.workspaceId(),
ProposalStatus.PENDING);
return proposals.stream().map(this::toDto).toList();
}
@Transactional(readOnly = true)
public List<RelationProposalDto> getAllProposals() {
return getAllProposalsInContext(
workspaceResolver.resolveCurrentRepositoryContext());
}
@Transactional(readOnly = true)
public List<RelationProposalDto> getAllProposalsInContext(
RepositoryContext context) {
RepositoryContext tenant = requireContext(context);
List<RelationProposal> proposals = tenant.workspaceId() == null
? proposalRepository.findCentralByRepository(tenant.repositoryId())
: proposalRepository.findVisibleByRepositoryAndWorkspace(
tenant.repositoryId(), tenant.workspaceId());
return proposals.stream().map(this::toDto).toList();
}
@Transactional(readOnly = true)
public List<RelationProposalDto> getProposalsForNode(String sourceCode) {
return getProposalsForNodeInContext(
sourceCode,
workspaceResolver.resolveCurrentRepositoryContext());
}
@Transactional(readOnly = true)
public List<RelationProposalDto> getProposalsForNodeInContext(
String sourceCode,
RepositoryContext context) {
RepositoryContext tenant = requireContext(context);
List<RelationProposal> proposals = tenant.workspaceId() == null
? proposalRepository.findCentralByRepositoryAndSourceNodeCode(
tenant.repositoryId(), sourceCode)
: proposalRepository.findVisibleByRepositoryAndWorkspaceAndSourceNodeCode(
tenant.repositoryId(), tenant.workspaceId(), sourceCode);
return proposals.stream().map(this::toDto).toList();
}
/**
* Resolve one request-stable context and create a proposal from a hypothesis.
* Central read-only contexts fail closed for the same reason as
* {@link #proposeRelations(String, RelationType, int)}.
*/
@Transactional
public RelationProposalDto createFromHypothesis(
String sourceCode,
String targetCode,
RelationType relationType,
double confidence,
String rationale) {
return createFromHypothesisInContext(
sourceCode,
targetCode,
relationType,
confidence,
rationale,
workspaceResolver.resolveCurrentRepositoryContext());
}
/** Creates a formal proposal from a provisional hypothesis in the exact writable tenant. */
@Transactional
public RelationProposalDto createFromHypothesisInContext(
String sourceCode,
String targetCode,
RelationType relationType,
double confidence,
String rationale,
RepositoryContext context) {
RepositoryContext tenant = requireWritableContext(context);
TaxonomyNode source = nodeRepository.findByCode(sourceCode)
.orElseThrow(() -> new IllegalArgumentException(
"Source node not found: " + sourceCode));
TaxonomyNode target = nodeRepository.findByCode(targetCode)
.orElseThrow(() -> new IllegalArgumentException(
"Target node not found: " + targetCode));
if (proposalRepository.existsInRepositoryWorkspace(
tenant.repositoryId(),
sourceCode,
targetCode,
relationType,
tenant.workspaceId())) {
log.debug("Proposal already exists: {} → {} [{}] "
+ "(repository={}, workspace={})",
sourceCode,
targetCode,
relationType,
tenant.repositoryId(),
tenant.workspaceId());
return null;
}
IdentitySnapshot existingRelations =
relationReadService.readIdentitySnapshot(tenant);
if (existingRelations.contains(sourceCode, relationType, targetCode)) {
log.debug("Active relation already exists: {} → {} [{}] "
+ "(repository={}, workspace={}, branch={})",
sourceCode,
targetCode,
relationType,
tenant.repositoryId(),
tenant.workspaceId(),
tenant.branch());
return null;
}
RelationProposal proposal = new RelationProposal();
proposal.setRepositoryId(tenant.repositoryId());
proposal.setSourceNode(source);
proposal.setTargetNode(target);
proposal.setRelationType(relationType);
proposal.setConfidence(confidence);
proposal.setRationale(rationale);
proposal.setProvenance("analysis-hypothesis");
proposal.setStatus(ProposalStatus.PENDING);
proposal.setWorkspaceId(tenant.workspaceId());
proposal.setOwnerUsername(tenant.username());
RelationProposal saved = proposalRepository.save(proposal);
log.info("Created proposal from hypothesis: {} → {} [{}] confidence={} "
+ "(repository={}, workspace={})",
sourceCode,
targetCode,
relationType,
confidence,
tenant.repositoryId(),
tenant.workspaceId());
return toDto(saved);
}
public RelationProposalDto toDto(RelationProposal proposal) {
RelationProposalDto dto = new RelationProposalDto();
dto.setId(proposal.getId());
dto.setSourceCode(proposal.getSourceNode().getCode());
dto.setSourceName(proposal.getSourceNode().getNameEn());
dto.setTargetCode(proposal.getTargetNode().getCode());
dto.setTargetName(proposal.getTargetNode().getNameEn());
dto.setRelationType(proposal.getRelationType().name());
dto.setStatus(proposal.getStatus().name());
dto.setConfidence(proposal.getConfidence());
dto.setRationale(proposal.getRationale());
dto.setProvenance(proposal.getProvenance());
dto.setExplanationBasis(deriveExplanationBasis(proposal));
dto.setCreatedAt(proposal.getCreatedAt());
dto.setReviewedAt(proposal.getReviewedAt());
return dto;
}
private String deriveExplanationBasis(RelationProposal proposal) {
String provenance = proposal.getProvenance();
if (provenance == null) {
return "unknown source";
}
return switch (provenance) {
case "hybrid-search" -> "Discovered via hybrid search (semantic + keyword) "
+ "and validated against the relation compatibility matrix";
case "analysis-hypothesis" -> "Derived from LLM analysis of a business requirement";
default -> "Source: " + provenance;
};
}
private static RepositoryContext requireContext(RepositoryContext context) {
if (context == null) {
throw new IllegalArgumentException(
"RepositoryContext must not be null");
}
return context;
}
private static RepositoryContext requireWritableContext(
RepositoryContext context) {
RepositoryContext tenant = requireContext(context);
if (tenant.workspaceId() == null) {
if (tenant.scope() != RepositoryScope.CENTRAL_WRITE) {
throw new IllegalArgumentException(
"Central proposal mutation requires CENTRAL_WRITE scope");
}
return tenant;
}
if (tenant.scope() != RepositoryScope.WORKSPACE
&& tenant.scope() != RepositoryScope.FORK) {
throw new IllegalArgumentException(
"Workspace proposal mutation requires WORKSPACE or FORK scope");
}
return tenant;
}
}