RelationQualityService.java

package com.taxonomy.relations.service;

import com.taxonomy.dto.ProvenanceMetrics;
import com.taxonomy.dto.RelationQualityMetrics;
import com.taxonomy.dto.RelationTypeMetrics;
import com.taxonomy.dto.TopRejectedProposal;
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.workspace.service.RepositoryContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Comparator;
import java.util.List;
import java.util.Objects;

/**
 * Computes relation-review quality metrics inside one selected repository
 * context and provides the same scoped history as a confidence signal.
 *
 * <p>A central context sees central reviews in exactly one repository. A
 * workspace context intentionally inherits those central reviews and adds only
 * reviews from that exact workspace. Sibling workspaces and other repositories
 * are never part of the normal product view.</p>
 */
@Service
public class RelationQualityService {

    private final RelationProposalRepository proposalRepository;

    public RelationQualityService(RelationProposalRepository proposalRepository) {
        this.proposalRepository = Objects.requireNonNull(
                proposalRepository, "proposalRepository");
    }

    /** Computes the full dashboard from one request-stable visible snapshot. */
    @Transactional(readOnly = true)
    public RelationQualityMetrics calculateMetrics(RepositoryContext context) {
        List<RelationProposal> proposals = visibleProposals(context);
        long accepted = countByStatus(proposals, ProposalStatus.ACCEPTED);
        long rejected = countByStatus(proposals, ProposalStatus.REJECTED);
        long pending = countByStatus(proposals, ProposalStatus.PENDING);
        long reviewed = accepted + rejected;

        return new RelationQualityMetrics(
                Math.toIntExact(accepted + rejected + pending),
                Math.toIntExact(accepted),
                Math.toIntExact(rejected),
                Math.toIntExact(pending),
                reviewed > 0 ? (double) accepted / reviewed : 0.0,
                averageConfidence(proposals, ProposalStatus.ACCEPTED),
                averageConfidence(proposals, ProposalStatus.REJECTED),
                metricsByRelationType(proposals),
                metricsByProvenance(proposals));
    }

    /** Returns metrics by relation type in the exact selected context. */
    @Transactional(readOnly = true)
    public List<RelationTypeMetrics> metricsByRelationType(
            RepositoryContext context) {
        return metricsByRelationType(visibleProposals(context));
    }

    /** Returns metrics by provenance in the exact selected context. */
    @Transactional(readOnly = true)
    public List<ProvenanceMetrics> metricsByProvenance(
            RepositoryContext context) {
        return metricsByProvenance(visibleProposals(context));
    }

    /**
     * Returns rejected proposals visible in the selected context, ordered by
     * descending confidence (the worst false positives first).
     */
    @Transactional(readOnly = true)
    public List<TopRejectedProposal> topRejected(
            int limit,
            RepositoryContext context) {
        if (limit <= 0) {
            return List.of();
        }
        return visibleProposalsByStatus(
                        context, ProposalStatus.REJECTED).stream()
                .sorted(Comparator
                        .comparingDouble(RelationProposal::getConfidence)
                        .reversed()
                        .thenComparing(
                                RelationProposal::getId,
                                Comparator.nullsLast(Long::compareTo)))
                .limit(limit)
                .map(proposal -> new TopRejectedProposal(
                        proposal.getSourceNode().getCode(),
                        proposal.getSourceNode().getNameEn(),
                        proposal.getTargetNode().getCode(),
                        proposal.getTargetNode().getNameEn(),
                        proposal.getRelationType().name(),
                        proposal.getConfidence(),
                        proposal.getRationale()))
                .toList();
    }

    /**
     * Returns a feedback weight in {@code [0.0, 1.0]} from review history
     * visible in the exact proposal-generation context.
     *
     * <p>Returns {@code 0.5} when no reviewed history exists. A workspace
     * intentionally inherits central review history from the same repository,
     * but never history from a sibling workspace or another repository.</p>
     */
    @Transactional(readOnly = true)
    public double acceptanceHistoryWeight(
            String sourceRoot,
            String targetRoot,
            RelationType relationType,
            RepositoryContext context) {
        RepositoryContext tenant = requireContext(context);
        long accepted = proposalRepository.countVisibleReviewHistory(
                tenant.repositoryId(),
                tenant.workspaceId(),
                sourceRoot,
                targetRoot,
                relationType,
                ProposalStatus.ACCEPTED);
        long rejected = proposalRepository.countVisibleReviewHistory(
                tenant.repositoryId(),
                tenant.workspaceId(),
                sourceRoot,
                targetRoot,
                relationType,
                ProposalStatus.REJECTED);
        long reviewed = accepted + rejected;
        return reviewed == 0 ? 0.5 : (double) accepted / reviewed;
    }

    private List<RelationProposal> visibleProposals(
            RepositoryContext context) {
        RepositoryContext tenant = requireContext(context);
        if (tenant.workspaceId() == null) {
            return proposalRepository.findCentralByRepository(
                    tenant.repositoryId());
        }
        return proposalRepository.findVisibleByRepositoryAndWorkspace(
                tenant.repositoryId(), tenant.workspaceId());
    }

    private List<RelationProposal> visibleProposalsByStatus(
            RepositoryContext context,
            ProposalStatus status) {
        RepositoryContext tenant = requireContext(context);
        if (tenant.workspaceId() == null) {
            return proposalRepository.findCentralByRepositoryAndStatus(
                    tenant.repositoryId(), status);
        }
        return proposalRepository.findVisibleByRepositoryAndWorkspaceAndStatus(
                tenant.repositoryId(), tenant.workspaceId(), status);
    }

    private static List<RelationTypeMetrics> metricsByRelationType(
            List<RelationProposal> proposals) {
        return proposals.stream()
                .map(RelationProposal::getRelationType)
                .filter(Objects::nonNull)
                .distinct()
                .sorted()
                .map(relationType -> {
                    long accepted = countByTypeAndStatus(
                            proposals, relationType, ProposalStatus.ACCEPTED);
                    long rejected = countByTypeAndStatus(
                            proposals, relationType, ProposalStatus.REJECTED);
                    long pending = countByTypeAndStatus(
                            proposals, relationType, ProposalStatus.PENDING);
                    long reviewed = accepted + rejected;
                    return new RelationTypeMetrics(
                            relationType.name(),
                            Math.toIntExact(reviewed + pending),
                            Math.toIntExact(accepted),
                            Math.toIntExact(rejected),
                            reviewed > 0 ? (double) accepted / reviewed : 0.0);
                })
                .toList();
    }

    private static List<ProvenanceMetrics> metricsByProvenance(
            List<RelationProposal> proposals) {
        return proposals.stream()
                .map(RelationProposal::getProvenance)
                .distinct()
                .sorted(Comparator.nullsFirst(String::compareTo))
                .map(provenance -> {
                    long accepted = countByProvenanceAndStatus(
                            proposals, provenance, ProposalStatus.ACCEPTED);
                    long rejected = countByProvenanceAndStatus(
                            proposals, provenance, ProposalStatus.REJECTED);
                    long pending = countByProvenanceAndStatus(
                            proposals, provenance, ProposalStatus.PENDING);
                    long reviewed = accepted + rejected;
                    return new ProvenanceMetrics(
                            provenance,
                            Math.toIntExact(reviewed + pending),
                            Math.toIntExact(accepted),
                            reviewed > 0 ? (double) accepted / reviewed : 0.0);
                })
                .toList();
    }

    private static long countByStatus(
            List<RelationProposal> proposals,
            ProposalStatus status) {
        return proposals.stream()
                .filter(proposal -> proposal.getStatus() == status)
                .count();
    }

    private static long countByTypeAndStatus(
            List<RelationProposal> proposals,
            RelationType relationType,
            ProposalStatus status) {
        return proposals.stream()
                .filter(proposal -> proposal.getRelationType() == relationType)
                .filter(proposal -> proposal.getStatus() == status)
                .count();
    }

    private static long countByProvenanceAndStatus(
            List<RelationProposal> proposals,
            String provenance,
            ProposalStatus status) {
        return proposals.stream()
                .filter(proposal -> Objects.equals(
                        proposal.getProvenance(), provenance))
                .filter(proposal -> proposal.getStatus() == status)
                .count();
    }

    private static double averageConfidence(
            List<RelationProposal> proposals,
            ProposalStatus status) {
        return proposals.stream()
                .filter(proposal -> proposal.getStatus() == status)
                .mapToDouble(RelationProposal::getConfidence)
                .average()
                .orElse(0.0);
    }

    private static RepositoryContext requireContext(
            RepositoryContext context) {
        return Objects.requireNonNull(
                context, "RepositoryContext must not be null");
    }
}