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.repository.RelationProposalRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import com.taxonomy.relations.model.RelationProposal;

/**
 * Service for computing quality metrics over {@link com.taxonomy.relations.model.RelationProposal}
 * entities and providing a feedback signal for confidence scoring.
 */
@Service
public class RelationQualityService {

    private final RelationProposalRepository proposalRepository;

    public RelationQualityService(RelationProposalRepository proposalRepository) {
        this.proposalRepository = proposalRepository;
    }

    /**
     * Computes the full quality dashboard metrics.
     */
    @Transactional(readOnly = true)
    public RelationQualityMetrics calculateMetrics() {
        long accepted = proposalRepository.countByStatus(ProposalStatus.ACCEPTED);
        long rejected = proposalRepository.countByStatus(ProposalStatus.REJECTED);
        long pending  = proposalRepository.countByStatus(ProposalStatus.PENDING);
        long total    = accepted + rejected + pending;

        double acceptanceRate = (accepted + rejected) > 0
                ? (double) accepted / (accepted + rejected) : 0.0;

        Double avgAccepted = proposalRepository.avgConfidenceByStatus(ProposalStatus.ACCEPTED);
        Double avgRejected = proposalRepository.avgConfidenceByStatus(ProposalStatus.REJECTED);

        return new RelationQualityMetrics(
                (int) total,
                (int) accepted,
                (int) rejected,
                (int) pending,
                acceptanceRate,
                avgAccepted != null ? avgAccepted : 0.0,
                avgRejected != null ? avgRejected : 0.0,
                metricsByRelationType(),
                metricsByProvenance()
        );
    }

    /**
     * Aggregates metrics broken down by relation type.
     */
    @Transactional(readOnly = true)
    public List<RelationTypeMetrics> metricsByRelationType() {
        return proposalRepository.findDistinctRelationTypes().stream()
                .map(rt -> {
                    long accepted = proposalRepository.countByRelationTypeAndStatus(rt, ProposalStatus.ACCEPTED);
                    long rejected = proposalRepository.countByRelationTypeAndStatus(rt, ProposalStatus.REJECTED);
                    long pending  = proposalRepository.countByRelationTypeAndStatus(rt, ProposalStatus.PENDING);
                    long proposed = accepted + rejected + pending;
                    double rate   = (accepted + rejected) > 0
                            ? (double) accepted / (accepted + rejected) : 0.0;
                    return new RelationTypeMetrics(rt.name(), (int) proposed, (int) accepted, (int) rejected, rate);
                })
                .toList();
    }

    /**
     * Aggregates metrics broken down by provenance string.
     */
    @Transactional(readOnly = true)
    public List<ProvenanceMetrics> metricsByProvenance() {
        return proposalRepository.findDistinctProvenances().stream()
                .map(prov -> {
                    long accepted = proposalRepository.countByProvenanceAndStatus(prov, ProposalStatus.ACCEPTED);
                    long rejected = proposalRepository.countByProvenanceAndStatus(prov, ProposalStatus.REJECTED);
                    long pending  = proposalRepository.countByProvenanceAndStatus(prov, ProposalStatus.PENDING);
                    long proposed = accepted + rejected + pending;
                    double rate   = (accepted + rejected) > 0
                            ? (double) accepted / (accepted + rejected) : 0.0;
                    return new ProvenanceMetrics(prov, (int) proposed, (int) accepted, rate);
                })
                .toList();
    }

    /**
     * Returns the top rejected proposals ordered by confidence descending (worst false positives first).
     */
    @Transactional(readOnly = true)
    public List<TopRejectedProposal> topRejected(int limit) {
        return proposalRepository.findByStatusOrderByConfidenceDesc(ProposalStatus.REJECTED)
                .stream()
                .limit(limit)
                .map(p -> new TopRejectedProposal(
                        p.getSourceNode().getCode(),
                        p.getSourceNode().getNameEn(),
                        p.getTargetNode().getCode(),
                        p.getTargetNode().getNameEn(),
                        p.getRelationType().name(),
                        p.getConfidence(),
                        p.getRationale()
                ))
                .toList();
    }

    /**
     * Returns a feedback weight [0.0, 1.0] derived from the acceptance history
     * for the given source root, target root, and relation type.
     *
     * <p>Returns 0.5 (neutral) when no history exists.
     */
    public double acceptanceHistoryWeight(String sourceRoot, String targetRoot, RelationType relationType) {
        long accepted = proposalRepository
                .countBySourceNodeTaxonomyRootAndTargetNodeTaxonomyRootAndRelationTypeAndStatus(
                        sourceRoot, targetRoot, relationType, ProposalStatus.ACCEPTED);
        long rejected = proposalRepository
                .countBySourceNodeTaxonomyRootAndTargetNodeTaxonomyRootAndRelationTypeAndStatus(
                        sourceRoot, targetRoot, relationType, ProposalStatus.REJECTED);

        if ((accepted + rejected) == 0) {
            return 0.5;
        }
        return (double) accepted / (accepted + rejected);
    }
}