Files

431 lines
13 KiB
Go
Raw Permalink Normal View History

package service
import (
"context"
"fmt"
"strings"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// WikiLintIssueType defines the type of lint issue
type WikiLintIssueType string
const (
LintIssueOrphanPage WikiLintIssueType = "orphan_page"
LintIssueBrokenLink WikiLintIssueType = "broken_link"
LintIssueStaleRef WikiLintIssueType = "stale_ref"
LintIssueMissingCrossRef WikiLintIssueType = "missing_cross_ref"
LintIssueEmptyContent WikiLintIssueType = "empty_content"
LintIssueDuplicateSlug WikiLintIssueType = "duplicate_slug"
)
// WikiLintIssueSeverity defines the severity of a lint issue
type WikiLintIssueSeverity string
const (
SeverityInfo WikiLintIssueSeverity = "info"
SeverityWarning WikiLintIssueSeverity = "warning"
SeverityError WikiLintIssueSeverity = "error"
)
// WikiLintIssue represents a single lint finding
type WikiLintIssue struct {
Type WikiLintIssueType `json:"type"`
Severity WikiLintIssueSeverity `json:"severity"`
PageSlug string `json:"page_slug"`
// TargetSlug identifies the other page involved in the issue (e.g. the
// broken link target, or the entity slug for a missing cross-ref). It is
// the structured field used by AutoFix instead of parsing Description.
TargetSlug string `json:"target_slug,omitempty"`
Description string `json:"description"`
AutoFixable bool `json:"auto_fixable"`
}
// WikiLintReport is the complete lint report for a wiki KB
type WikiLintReport struct {
KnowledgeBaseID string `json:"knowledge_base_id"`
Issues []WikiLintIssue `json:"issues"`
HealthScore int `json:"health_score"` // 0-100
Stats *types.WikiStats `json:"stats"`
Summary string `json:"summary"`
}
// WikiLintService provides wiki health checking capabilities
type WikiLintService struct {
wikiService interfaces.WikiPageService
kbService interfaces.KnowledgeBaseService
knowledgeService interfaces.KnowledgeService
}
// NewWikiLintService creates a new wiki lint service
func NewWikiLintService(
wikiService interfaces.WikiPageService,
kbService interfaces.KnowledgeBaseService,
knowledgeService interfaces.KnowledgeService,
) *WikiLintService {
return &WikiLintService{
wikiService: wikiService,
kbService: kbService,
knowledgeService: knowledgeService,
}
}
// lintCursorBatch is the per-batch limit for the streaming page walk.
// Picked at 200 because wiki pages can carry multi-KB content blobs
// and 200 rows × ~20KB ≈ 4MB resident at a time, which is well within
// what we want to hold while running per-page checks.
const lintCursorBatch = 200
// RunLint performs a comprehensive health check on a wiki knowledge base.
//
// At 4w-document scale the legacy "load every page in one shot"
// approach was the dominant tail in this method (and intermittently
// caused OOM in production). We now walk the page set via
// ListPagesCursor in lintCursorBatch-sized windows, accumulating
// issues incrementally — memory stays bounded regardless of KB size.
//
// We also drop the GetGraph(Limit:0) call that the legacy path used
// to compute the live-slug set. ListAllSlugs is a one-column projection
// over the same predicate (kbID + status<>archived), so it gives the
// same answer at a fraction of the cost.
func (s *WikiLintService) RunLint(ctx context.Context, kbID string) (*WikiLintReport, error) {
// Validate KB
kb, err := s.kbService.GetKnowledgeBaseByIDOnly(ctx, kbID)
if err != nil {
return nil, fmt.Errorf("get KB: %w", err)
}
if !kb.IsWikiEnabled() {
return nil, fmt.Errorf("KB %s is not a wiki type", kbID)
}
// Get stats
stats, err := s.wikiService.GetStats(ctx, kbID)
if err != nil {
return nil, fmt.Errorf("get stats: %w", err)
}
// Compute the live-slug set from the cheap one-column projection.
// This replaces a full GetGraph call (which materialized every node
// + edge) with a single Pluck("slug") query.
liveSlugs, err := s.wikiService.ListAllSlugs(ctx, kbID)
if err != nil {
return nil, fmt.Errorf("list all slugs: %w", err)
}
slugSet := make(map[string]bool, len(liveSlugs))
for _, slug := range liveSlugs {
slugSet[slug] = true
}
var issues []WikiLintIssue
healthScore := 100
knowledgeLive := make(map[string]bool) // kid -> exists; cached across pages
// First-pass walk: orphan / broken-link / empty / stale-ref
// detection. Each check is independent of order; we accumulate
// issues per-batch and the cursor walk keeps memory bounded.
//
// We collect entity / concept titles in this pass too so the
// missing-cross-ref check (which is intrinsically O(N×M) in
// distinct entities × pages) doesn't need a second walk to find
// candidates. The check itself runs in a second walk because it
// needs the full entity-title set to compare against any page.
entitySlugs := make(map[string]string) // slug -> title
cursor := ""
for {
pages, next, err := s.wikiService.ListPagesCursor(ctx, kbID, cursor, lintCursorBatch)
if err != nil {
return nil, fmt.Errorf("list pages cursor: %w", err)
}
if len(pages) == 0 {
break
}
for _, page := range pages {
// Track entity/concept titles for the second pass.
if page.PageType == types.WikiPageTypeEntity || page.PageType == types.WikiPageTypeConcept {
entitySlugs[page.Slug] = page.Title
}
// Check 1: Orphan pages (no inbound links, excluding system pages).
if page.PageType != types.WikiPageTypeIndex {
if len(page.InLinks) == 0 {
issues = append(issues, WikiLintIssue{
Type: LintIssueOrphanPage,
Severity: SeverityWarning,
PageSlug: page.Slug,
Description: fmt.Sprintf("Page '%s' has no inbound links — it's disconnected from the wiki", page.Title),
AutoFixable: false,
})
}
}
// Check 2: Broken links — outlinks pointing at slugs that
// don't exist in the live set.
for _, outLink := range page.OutLinks {
if !slugSet[outLink] {
issues = append(issues, WikiLintIssue{
Type: LintIssueBrokenLink,
Severity: SeverityError,
PageSlug: page.Slug,
TargetSlug: outLink,
Description: fmt.Sprintf("Page '%s' links to [[%s]] which does not exist", page.Title, outLink),
AutoFixable: true,
})
}
}
// Check 3: Empty content.
content := strings.TrimSpace(page.Content)
if len(content) < 50 {
issues = append(issues, WikiLintIssue{
Type: LintIssueEmptyContent,
Severity: SeverityWarning,
PageSlug: page.Slug,
Description: fmt.Sprintf("Page '%s' has very little content (%d chars)", page.Title, len(content)),
AutoFixable: true,
})
}
// Check 4: Stale source refs — source_refs pointing at
// soft-deleted knowledge. Cached knowledgeLive lookup keeps
// per-kid checks O(1) after the first batch encounters
// each id.
if s.knowledgeService != nil && page.PageType != types.WikiPageTypeIndex {
for _, ref := range page.SourceRefs {
kid := ref
if i := strings.Index(ref, "|"); i > 0 {
kid = ref[:i]
}
if kid == "" {
continue
}
live, seen := knowledgeLive[kid]
if !seen {
kn, err := s.knowledgeService.GetKnowledgeByIDOnly(ctx, kid)
live = err == nil && kn != nil
knowledgeLive[kid] = live
}
if !live {
issues = append(issues, WikiLintIssue{
Type: LintIssueStaleRef,
Severity: SeverityError,
PageSlug: page.Slug,
TargetSlug: kid,
Description: fmt.Sprintf("Page '%s' references deleted knowledge %s", page.Title, kid),
AutoFixable: true,
})
}
}
}
}
if next == "" {
break
}
cursor = next
}
// Second-pass walk: missing-cross-ref check. This needs the full
// entitySlugs map (built in pass 1), so it has to be a separate
// pass — but it's still streaming.
cursor = ""
for {
pages, next, err := s.wikiService.ListPagesCursor(ctx, kbID, cursor, lintCursorBatch)
if err != nil {
return nil, fmt.Errorf("list pages cursor (pass 2): %w", err)
}
if len(pages) == 0 {
break
}
for _, page := range pages {
lowerContent := strings.ToLower(page.Content)
outLinkSet := make(map[string]struct{}, len(page.OutLinks))
for _, l := range page.OutLinks {
outLinkSet[l] = struct{}{}
}
for slug, title := range entitySlugs {
if slug == page.Slug || title == "" {
continue
}
if !strings.Contains(lowerContent, strings.ToLower(title)) {
continue
}
if _, linked := outLinkSet[slug]; linked {
continue
}
issues = append(issues, WikiLintIssue{
Type: LintIssueMissingCrossRef,
Severity: SeverityInfo,
PageSlug: page.Slug,
TargetSlug: slug,
Description: fmt.Sprintf("Page '%s' mentions '%s' but doesn't link to [[%s]]", page.Title, title, slug),
AutoFixable: false,
})
}
}
if next == "" {
break
}
cursor = next
}
// Calculate health score
if stats.TotalPages > 0 {
// Penalize for orphans
orphanPct := float64(stats.OrphanCount) / float64(stats.TotalPages) * 100
if orphanPct > 50 {
healthScore -= 25
} else if orphanPct > 25 {
healthScore -= 10
}
// Penalize for broken links
brokenCount := 0
for _, issue := range issues {
if issue.Type == LintIssueBrokenLink {
brokenCount++
}
}
healthScore -= brokenCount * 5
// Penalize for no links at all
if stats.TotalLinks == 0 && stats.TotalPages > 2 {
healthScore -= 15
}
// Penalize for empty pages
emptyCount := 0
for _, issue := range issues {
if issue.Type == LintIssueEmptyContent {
emptyCount++
}
}
healthScore -= emptyCount * 3
}
if healthScore < 0 {
healthScore = 0
}
// Generate summary
var summary strings.Builder
errorCount := 0
warningCount := 0
infoCount := 0
for _, issue := range issues {
switch issue.Severity {
case SeverityError:
errorCount++
case SeverityWarning:
warningCount++
case SeverityInfo:
infoCount++
}
}
if len(issues) == 0 {
summary.WriteString("Wiki is healthy! No issues found.")
} else {
fmt.Fprintf(&summary, "Found %d issues: %d errors, %d warnings, %d suggestions.",
len(issues), errorCount, warningCount, infoCount)
}
report := &WikiLintReport{
KnowledgeBaseID: kbID,
Issues: issues,
HealthScore: healthScore,
Stats: stats,
Summary: summary.String(),
}
logger.Infof(ctx, "wiki lint: KB %s — health score %d/100, %d issues", kbID, healthScore, len(issues))
return report, nil
}
// AutoFix attempts to automatically fix fixable issues
func (s *WikiLintService) AutoFix(ctx context.Context, kbID string) (int, error) {
report, err := s.RunLint(ctx, kbID)
if err != nil {
return 0, err
}
fixed := 0
for _, issue := range report.Issues {
if !issue.AutoFixable {
continue
}
switch issue.Type {
case LintIssueBrokenLink:
// Replace [[broken-slug]] with plain text so the reference text is
// preserved but no longer renders as a dangling wiki link.
if issue.TargetSlug == "" {
continue
}
page, err := s.wikiService.GetPageBySlug(ctx, kbID, issue.PageSlug)
if err != nil {
continue
}
target := issue.TargetSlug
page.Content = strings.ReplaceAll(page.Content, "[["+target+"]]", target)
if err := s.wikiService.UpdateAutoLinkedContent(ctx, page); err == nil {
fixed++
}
case LintIssueEmptyContent:
// Archive pages with very little content instead of deleting
page, err := s.wikiService.GetPageBySlug(ctx, kbID, issue.PageSlug)
if err != nil {
continue
}
// Don't archive the index page.
if page.PageType == types.WikiPageTypeIndex {
continue
}
page.Status = types.WikiPageStatusArchived
if _, err := s.wikiService.UpdatePage(ctx, page); err == nil {
fixed++
}
case LintIssueStaleRef:
// Strip source_refs that point at soft-deleted knowledge. If the
// page has no other live sources, delete it outright — leaving
// an orphan summary page is worse than removing it, because the
// model would still link to it from other pages and the
// wiki_read_source_doc drill-down would always fail.
if issue.TargetSlug == "" {
continue
}
page, err := s.wikiService.GetPageBySlug(ctx, kbID, issue.PageSlug)
if err != nil || page == nil {
continue
}
if page.PageType == types.WikiPageTypeIndex {
continue
}
remaining := removeSourceRef(page.SourceRefs, issue.TargetSlug)
if len(remaining) == 0 {
if err := s.wikiService.DeletePage(ctx, kbID, page.Slug); err == nil {
fixed++
}
} else if len(remaining) != len(page.SourceRefs) {
page.SourceRefs = remaining
if err := s.wikiService.UpdatePageMeta(ctx, page); err == nil {
fixed++
}
}
}
}
// Rebuild links after fixes
if fixed > 0 {
_ = s.wikiService.RebuildLinks(ctx, kbID)
}
logger.Infof(ctx, "wiki auto-fix: KB %s — fixed %d issues", kbID, fixed)
return fixed, nil
}