ProvisionalRelationStep.java

package com.taxonomy.architecture.pipeline;

import com.taxonomy.catalog.model.TaxonomyNode;
import com.taxonomy.catalog.repository.TaxonomyNodeRepository;
import com.taxonomy.catalog.service.TaxonomyService;
import com.taxonomy.dto.NodeOrigin;
import com.taxonomy.dto.RelationHypothesisDto;
import com.taxonomy.dto.RequirementElementView;
import com.taxonomy.dto.RequirementRelationshipView;
import com.taxonomy.dto.RelationOrigin;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * Injects provisional (AI-suggested) relation hypotheses as virtual edges
 * when no confirmed relations exist.
 *
 * <p>When the relationship list is empty but provisional relations are available,
 * this step adds the hypothesis endpoints to the element list (if not already present)
 * and creates corresponding {@link RequirementRelationshipView} entries marked as
 * {@code provisional (AI-suggested, not yet confirmed)}.
 *
 * <p>This ensures the architecture view is immediately useful even before any
 * confirmed taxonomy relations have been saved.
 *
 * <p><b>Safe extension point</b> — this step may be replaced or augmented
 * to change how provisional relations are injected, provided the context
 * element and relationship lists remain in a consistent state on exit.
 */
@Service
public class ProvisionalRelationStep implements ArchitecturePipelineStep {

    /** Stable pipeline step ID. */
    public static final String STEP_ID = "provisional-relation";

    private final TaxonomyNodeRepository nodeRepository;
    private final TaxonomyService taxonomyService;

    public ProvisionalRelationStep(TaxonomyNodeRepository nodeRepository,
                                   TaxonomyService taxonomyService) {
        this.nodeRepository = nodeRepository;
        this.taxonomyService = taxonomyService;
    }

    @Override
    public String id() { return STEP_ID; }

    @Override
    public int order() { return 600; }

    @Override
    public void apply(ArchitectureViewContext ctx) {
        List<RequirementRelationshipView> relationships = ctx.getRelationships();
        List<RelationHypothesisDto> provisionalRelations = ctx.getProvisionalRelations();

        if (!relationships.isEmpty()
                || provisionalRelations == null
                || provisionalRelations.isEmpty()) {
            return;
        }

        List<RequirementElementView> elements = ctx.getElements();
        Set<String> includedCodes = elements.stream()
                .map(RequirementElementView::getNodeCode)
                .collect(Collectors.toSet());

        boolean addedProvisional = false;
        for (RelationHypothesisDto hyp : provisionalRelations) {
            if (!hasText(hyp.getSourceCode()) || !hasText(hyp.getTargetCode())) {
                continue;
            }

            ensureElement(elements, includedCodes, hyp.getSourceCode(), hyp.getSourceName(),
                    ctx.getScores(), ctx);
            ensureElement(elements, includedCodes, hyp.getTargetCode(), hyp.getTargetName(),
                    ctx.getScores(), ctx);

            RequirementRelationshipView rv = new RequirementRelationshipView();
            rv.setSourceCode(hyp.getSourceCode());
            rv.setTargetCode(hyp.getTargetCode());
            rv.setRelationType(hyp.getRelationType());
            rv.setPropagatedRelevance(hyp.getConfidence());
            rv.setHopDistance(0);
            rv.setIncludedBecause("provisional (AI-suggested, not yet confirmed)");
            relationships.add(rv);
            addedProvisional = true;
        }

        ctx.setUsedProvisional(addedProvisional);
    }

    private void ensureElement(List<RequirementElementView> elements,
                                Set<String> includedCodes,
                                String nodeCode, String nodeName,
                                java.util.Map<String, Integer> scores,
                                ArchitectureViewContext ctx) {
        if (includedCodes.contains(nodeCode)) {
            return;
        }
        RequirementElementView element = new RequirementElementView();
        element.setNodeCode(nodeCode);
        element.setRelevance(scores.getOrDefault(nodeCode, 0) / 100.0);
        element.setHopDistance(0);
        element.setAnchor(false);
        element.setIncludedBecause("provisional relation endpoint");
        element.setOrigin(NodeOrigin.SEED_CONTEXT);
        element.setDirectLlmScore(scores.getOrDefault(nodeCode, 0));
        element.setTitle(nodeName);

        Optional<TaxonomyNode> nodeOpt = nodeRepository.findByCode(nodeCode);
        if (nodeOpt.isPresent()) {
            TaxonomyNode node = nodeOpt.get();
            if (element.getTitle() == null) {
                element.setTitle(node.getNameEn());
            }
            element.setTaxonomySheet(node.getTaxonomyRoot());
        }

        element.setHierarchyPath(ctx.buildHierarchyPath(nodeCode, taxonomyService));

        elements.add(element);
        includedCodes.add(nodeCode);
    }

    private static boolean hasText(String value) {
        return value != null && !value.isBlank();
    }
}