package vip.mate.wiki.service; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import vip.mate.wiki.dto.*; import vip.mate.wiki.metrics.WikiMetrics; import vip.mate.wiki.model.WikiPageEntity; import vip.mate.wiki.relation.RelationSignalStrategy; import vip.mate.wiki.repository.WikiPageCitationMapper; import vip.mate.wiki.repository.WikiPageMapper; import java.time.Duration; import java.util.*; import java.util.stream.Collectors; /** * RFC-029: Wiki relation service — computes multi-signal structural * relevance between pages using all registered {@link RelationSignalStrategy} beans. */ @Slf4j @Service public class WikiRelationService { private final List signals; private final WikiPageMapper pageMapper; private final WikiPageService pageService; private final WikiPageCitationMapper citationMapper; private final WikiMetrics metrics; public WikiRelationService(List signals, WikiPageMapper pageMapper, WikiPageService pageService, WikiPageCitationMapper citationMapper, WikiMetrics metrics) { this.signals = signals; this.pageMapper = pageMapper; this.pageService = pageService; this.citationMapper = citationMapper; this.metrics = metrics; } /** * Find pages related to a seed page, ranked by multi-signal score. */ public List relatedPages(Long kbId, String seedSlug, int topK) { WikiPageEntity seed = pageService.getBySlug(kbId, seedSlug); if (seed == null) return List.of(); long startNanos = System.nanoTime(); Map totalScores = new HashMap<>(); Map> signalHits = new HashMap<>(); for (RelationSignalStrategy signal : signals) { try { signal.score(seed.getId(), kbId).forEach((pid, s) -> { totalScores.merge(pid, s, Double::sum); signalHits.computeIfAbsent(pid, k -> new ArrayList<>()).add(signal.signalName()); }); } catch (Exception e) { log.warn("[WikiRelation] Signal '{}' failed for seed={}: {}", signal.signalName(), seedSlug, e.getMessage()); } } List topIds = totalScores.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .limit(topK) .map(Map.Entry::getKey) .toList(); // Reports both compute duration and the candidate-set size. // Cache hit/miss is reported once a relation cache layer is wired up; // until then every call is a cold compute by definition. metrics.recordRelationCompute(kbId, totalScores.size(), Duration.ofNanos(System.nanoTime() - startNanos)); metrics.recordRelationCacheHit(false); if (topIds.isEmpty()) return List.of(); Map liteMap = pageMapper.selectBatchLite(topIds) .stream().collect(Collectors.toMap(WikiPageLite::id, l -> l)); return topIds.stream() .filter(liteMap::containsKey) // RFC-051 PR-2: hide system pages (overview / log) from related results. .filter(pid -> !liteMap.get(pid).isSystem()) .map(pid -> new RelatedPageResult( liteMap.get(pid).slug(), liteMap.get(pid).title(), liteMap.get(pid).summary(), totalScores.get(pid), signalHits.getOrDefault(pid, List.of()))) .toList(); } /** * Explain the relation between two pages with a per-signal breakdown. */ public RelationExplanation explain(Long kbId, String slugA, String slugB) { WikiPageEntity a = pageService.getBySlug(kbId, slugA); WikiPageEntity b = pageService.getBySlug(kbId, slugB); if (a == null || b == null) return RelationExplanation.notFound(); List breakdown = new ArrayList<>(); double total = 0; for (RelationSignalStrategy signal : signals) { try { Double score = signal.score(a.getId(), kbId).get(b.getId()); if (score != null && score > 0) { breakdown.add(new SignalScore(signal.signalName(), signal.weight(), score)); total += score; } } catch (Exception e) { log.warn("[WikiRelation] Signal '{}' failed for explain {}<->{}: {}", signal.signalName(), slugA, slugB, e.getMessage()); } } return new RelationExplanation(slugA, slugB, total, breakdown); } /** * Find all pages derived from a given raw material. */ public List pagesByRawId(Long rawId) { List pageIds = citationMapper.listPageIdsByRawId(rawId); if (pageIds.isEmpty()) return List.of(); return pageMapper.selectBatchLite(pageIds); } /** * Find all pages that cite a given chunk. */ public List pagesByChunkId(Long chunkId) { List pageIds = citationMapper.listPageIdsByChunkId(chunkId); if (pageIds.isEmpty()) return List.of(); return pageMapper.selectBatchLite(pageIds); } }