package service import ( "context" "errors" "fmt" "regexp" "slices" "sort" "strconv" "strings" "time" "github.com/Tencent/WeKnora/internal/application/repository" "github.com/Tencent/WeKnora/internal/logger" "github.com/Tencent/WeKnora/internal/types" "github.com/Tencent/WeKnora/internal/types/interfaces" "github.com/google/uuid" "github.com/redis/go-redis/v9" ) // wikiLinkRegex matches [[wiki-link]] syntax in markdown content var wikiLinkRegex = regexp.MustCompile(`\[\[([^\]]+)\]\]`) // wikiInlineChunkCitationRegex matches internal short handles emitted by the // wiki ingest prompt while it classifies supporting chunks. The stable source // relationship lives in WikiPage.ChunkRefs, so these handles have no meaning // to readers and must not leak into generated Markdown. var wikiInlineChunkCitationRegex = regexp.MustCompile(`[ \t]*\[c\d{3,}(?:\s*[,;]\s*c\d{3,})*\]`) func stripWikiInlineChunkCitations(content string) string { return wikiInlineChunkCitationRegex.ReplaceAllString(content, "") } func stripWikiPageInlineChunkCitations(page *types.WikiPage) { if page == nil { return } page.Content = stripWikiInlineChunkCitations(page.Content) page.Summary = stripWikiInlineChunkCitations(page.Summary) } // wikiPageService implements the WikiPageService interface type wikiPageService struct { repo interfaces.WikiPageRepository chunkRepo interfaces.ChunkRepository kbService interfaces.KnowledgeBaseService taskPendingRepo interfaces.TaskPendingOpsRepository redisClient *redis.Client } // NewWikiPageService creates a new wiki page service func NewWikiPageService( repo interfaces.WikiPageRepository, chunkRepo interfaces.ChunkRepository, kbService interfaces.KnowledgeBaseService, taskPendingRepo interfaces.TaskPendingOpsRepository, redisClient *redis.Client, ) interfaces.WikiPageService { return &wikiPageService{ repo: repo, chunkRepo: chunkRepo, kbService: kbService, taskPendingRepo: taskPendingRepo, redisClient: redisClient, } } // CreatePage creates a new wiki page func (s *wikiPageService) CreatePage(ctx context.Context, page *types.WikiPage) (*types.WikiPage, error) { if page.ID == "" { page.ID = uuid.New().String() } if page.Slug == "" { return nil, errors.New("wiki page slug is required") } if page.KnowledgeBaseID == "" { return nil, errors.New("knowledge_base_id is required") } if page.Status == "" { page.Status = types.WikiPageStatusPublished } if page.Version == 0 { page.Version = 1 } page.LastEditSource = types.WikiEditSourceFromContext(ctx) page.LastEditorID, _ = types.UserIDFromContext(ctx) stripWikiPageInlineChunkCitations(page) // Parse outbound links from content page.OutLinks = s.parseOutLinks(page.Content) if err := s.applyFolderToPage(ctx, page); err != nil { return nil, err } normalizeWikiHierarchy(page) now := time.Now() page.CreatedAt = now page.UpdatedAt = now if err := s.repo.Create(ctx, page); err != nil { return nil, fmt.Errorf("create wiki page: %w", err) } // Update inbound links on target pages s.updateInLinks(ctx, page.KnowledgeBaseID, page.Slug, page.OutLinks) return page, nil } // UpdatePage updates an existing wiki page. // // Version bump policy: the `version` column is intended to track the user- // visible content revision, not every row rewrite. We therefore bump it only // when at least one of the user-facing fields actually changes — title, // content, summary, page_type, or status. Bookkeeping-only writes (refreshing // source_refs after re-ingest when the body is identical, rebuilding the index // page with the same directory, cross-link injection that ends up replacing // nothing, etc.) still persist through `UpdateMeta` but leave `version` // untouched so consumers can treat a bump as a real edit signal. func (s *wikiPageService) UpdatePage(ctx context.Context, page *types.WikiPage) (*types.WikiPage, error) { existing, err := s.repo.GetBySlug(ctx, page.KnowledgeBaseID, page.Slug) if err != nil { return nil, fmt.Errorf("get existing page: %w", err) } if guard, ok := types.WikiPageWriteGuardFor(ctx, page.ID); ok && !guard.Matches(existing) { return nil, repository.ErrWikiPageConflict } writeCtx := types.WithWikiPageWriteGuard(ctx, existing) stripWikiPageInlineChunkCitations(page) oldOutLinks := existing.OutLinks // Snapshot user-visible fields BEFORE mutation so we can decide whether // this is a real content change or just bookkeeping. contentChanged := existing.Title != page.Title || existing.Content != page.Content || existing.Summary != page.Summary || existing.PageType != page.PageType || existing.Status != page.Status || !slices.Equal(existing.Aliases, page.Aliases) // Keep an unmutated copy of the version being replaced: it becomes the // revision snapshot when this turns out to be a real content change. prev := *existing existing.Title = page.Title existing.Content = page.Content existing.Summary = page.Summary existing.PageType = page.PageType existing.Aliases = append(types.StringArray(nil), page.Aliases...) existing.SourceRefs = page.SourceRefs existing.ChunkRefs = page.ChunkRefs existing.PageMetadata = page.PageMetadata existing.ParentSlug = page.ParentSlug existing.FolderID = page.FolderID existing.SortOrder = page.SortOrder existing.Status = page.Status existing.UpdatedAt = time.Now() // CategoryPath is a derived cache of FolderID — recompute it from the // folder chain rather than trusting whatever the caller sent. if err := s.applyFolderToPage(ctx, existing); err != nil { return nil, err } // Outbound links are a pure derivative of content, so they only shift // when content shifts. Re-parse unconditionally to stay consistent with // the stored body. existing.OutLinks = s.parseOutLinks(existing.Content) normalizeWikiHierarchy(existing) if contentChanged { // The new version is authored by whoever is driving this write. existing.LastEditSource = types.WikiEditSourceFromContext(ctx) existing.LastEditorID, _ = types.UserIDFromContext(ctx) // Snapshot the superseded version and write the new one atomically, // so the content of every past version is preserved and a failed // update leaves no snapshot behind. if err := s.repo.UpdateWithRevision(writeCtx, existing, revisionFromPage(&prev)); err != nil { return nil, fmt.Errorf("update wiki page: %w", err) } // Bound per-page history; best-effort — a failed prune only means // slightly more storage until the next content change. s.pruneRevisions(ctx, existing.ID, existing.Version) } else { // No user-visible change — persist bookkeeping fields but preserve // the version so downstream consumers can rely on it. if err := s.repo.UpdateMeta(writeCtx, existing); err != nil { return nil, fmt.Errorf("update wiki page meta: %w", err) } } // Update inbound links: remove old, add new. If content didn't change, // oldOutLinks == existing.OutLinks and these calls are effectively no-ops. s.removeInLinks(ctx, existing.KnowledgeBaseID, existing.Slug, oldOutLinks) s.updateInLinks(ctx, existing.KnowledgeBaseID, existing.Slug, existing.OutLinks) return existing, nil } // UpdatePageMeta updates only metadata (status, source_refs) without version bump or link re-parse. func (s *wikiPageService) UpdatePageMeta(ctx context.Context, page *types.WikiPage) error { if _, guarded := types.WikiPageWriteGuardFor(ctx, page.ID); !guarded { ctx = types.WithWikiPageWriteGuard(ctx, page) } normalizeWikiHierarchy(page) page.UpdatedAt = time.Now() return s.repo.UpdateMeta(ctx, page) } // UpdateAutoLinkedContent persists content produced by machine-only link // decorators (cross-link injection / dead-link cleanup) without bumping // `version`. Out-links are re-parsed from the new body and bidirectional // in-link references on target pages are refreshed so link navigation stays // consistent — only the user-facing revision counter is preserved. func (s *wikiPageService) UpdateAutoLinkedContent(ctx context.Context, page *types.WikiPage) error { writeCtx := ctx if _, guarded := types.WikiPageWriteGuardFor(writeCtx, page.ID); !guarded { writeCtx = types.WithWikiPageWriteGuard(writeCtx, page) } existing, err := s.repo.GetBySlug(ctx, page.KnowledgeBaseID, page.Slug) if err != nil { return fmt.Errorf("get existing page: %w", err) } if guard, _ := types.WikiPageWriteGuardFor(writeCtx, page.ID); !guard.Matches(existing) { return repository.ErrWikiPageConflict } oldOutLinks := existing.OutLinks existing.Content = stripWikiInlineChunkCitations(page.Content) existing.OutLinks = s.parseOutLinks(existing.Content) existing.UpdatedAt = time.Now() if err := s.repo.UpdateAutoLinkedContent(writeCtx, existing); err != nil { return fmt.Errorf("update auto-linked content: %w", err) } // Link rewrite compensation must use this successful write's snapshot. page.Content, page.OutLinks, page.UpdatedAt = existing.Content, existing.OutLinks, existing.UpdatedAt s.removeInLinks(ctx, existing.KnowledgeBaseID, existing.Slug, oldOutLinks) s.updateInLinks(ctx, existing.KnowledgeBaseID, existing.Slug, existing.OutLinks) return nil } // revisionFromPage builds the immutable snapshot row for the given page // state. EditSource on the snapshot is the author of THAT version — the // page's provenance columns as they stood while the version was current — // not whoever is performing the write that supersedes it. func revisionFromPage(p *types.WikiPage) *types.WikiPageRevision { return &types.WikiPageRevision{ ID: uuid.New().String(), TenantID: p.TenantID, KnowledgeBaseID: p.KnowledgeBaseID, PageID: p.ID, Slug: p.Slug, Version: p.Version, Title: p.Title, PageType: p.PageType, Status: p.Status, Content: p.Content, Summary: p.Summary, Aliases: append(types.StringArray(nil), p.Aliases...), EditSource: types.NormalizeWikiEditSource(p.LastEditSource), EditorID: p.LastEditorID, EditedAt: p.UpdatedAt, CreatedAt: time.Now(), } } // pruneRevisions bounds one page's snapshot history after it advanced to // currentVersion. Machine-authored snapshots are dropped once they fall out // of the recent window; human/agent/revert ones survive until the hard cap, // so pipeline churn on a hot page cannot evict the edits users care about. func (s *wikiPageService) pruneRevisions(ctx context.Context, pageID string, currentVersion int) { req := types.WikiRevisionPruneRequest{ PageID: pageID, KeepFromVersion: currentVersion - types.WikiMaxRevisionsPerPage, PrunableSources: types.WikiPrunableEditSources, HardKeepFromVersion: currentVersion - types.WikiMaxRevisionsHardCap, } if req.KeepFromVersion <= 0 && req.HardKeepFromVersion <= 0 { return } if err := s.repo.PruneRevisions(ctx, req); err != nil { logger.Warnf(ctx, "prune wiki page revisions for %s failed: %v", pageID, err) } } // ErrWikiRevertToCurrentVersion is returned when a revert targets the version // the page is already on — a client-side mistake (usually a stale history // list), not a server fault, so handlers map it to 400. var ErrWikiRevertToCurrentVersion = errors.New("cannot revert to the current version") // ListRevisions returns the stored historical snapshots for a page (newest // first, content omitted) plus the page's current version. func (s *wikiPageService) ListRevisions( ctx context.Context, kbID string, slug string, limit int, offset int, ) (*types.WikiPageRevisionListResponse, error) { page, err := s.repo.GetBySlug(ctx, kbID, slug) if err != nil { return nil, err } revs, total, err := s.repo.ListRevisions(ctx, kbID, page.ID, limit, offset) if err != nil { return nil, fmt.Errorf("list wiki page revisions: %w", err) } return &types.WikiPageRevisionListResponse{ Revisions: revs, Total: total, CurrentVersion: page.Version, }, nil } // GetRevision returns one historical snapshot with content. func (s *wikiPageService) GetRevision( ctx context.Context, kbID string, slug string, version int, ) (*types.WikiPageRevision, error) { page, err := s.repo.GetBySlug(ctx, kbID, slug) if err != nil { return nil, err } return s.repo.GetRevision(ctx, kbID, page.ID, version) } // RevertPageToVersion rolls the page back to a stored revision by applying // that revision's content fields as a regular edit: the pre-revert state is // snapshotted, version advances, links are re-parsed. Placement (folder, // sort order) and provenance refs keep their current values — a revert is // about content, not about undoing directory moves. func (s *wikiPageService) RevertPageToVersion( ctx context.Context, kbID string, slug string, version int, ) (*types.WikiPage, error) { page, err := s.repo.GetBySlug(ctx, kbID, slug) if err != nil { return nil, err } if version == page.Version { return nil, ErrWikiRevertToCurrentVersion } rev, err := s.repo.GetRevision(ctx, kbID, page.ID, version) if err != nil { return nil, err } target := *page target.Title = rev.Title target.Content = rev.Content target.Summary = rev.Summary target.PageType = rev.PageType target.Status = rev.Status target.Aliases = append(types.StringArray(nil), rev.Aliases...) return s.UpdatePage(types.WithWikiEditSource(ctx, types.WikiEditSourceRevert), &target) } // GetPageBySlug retrieves a wiki page by its slug func (s *wikiPageService) GetPageBySlug(ctx context.Context, kbID string, slug string) (*types.WikiPage, error) { page, err := s.repo.GetBySlug(ctx, kbID, slug) if err != nil { return nil, err } stripWikiPageInlineChunkCitations(page) return page, nil } // GetPageByID retrieves a wiki page by its ID func (s *wikiPageService) GetPageByID(ctx context.Context, id string) (*types.WikiPage, error) { page, err := s.repo.GetByID(ctx, id) if err != nil { return nil, err } stripWikiPageInlineChunkCitations(page) return page, nil } // ListPages lists wiki pages with optional filtering and pagination func (s *wikiPageService) ListPages(ctx context.Context, req *types.WikiPageListRequest) (*types.WikiPageListResponse, error) { pages, total, err := s.repo.List(ctx, req) if err != nil { return nil, err } for _, page := range pages { stripWikiPageInlineChunkCitations(page) normalizeWikiHierarchy(page) } pageSize := req.PageSize if pageSize < 1 { pageSize = 20 } page := req.Page if page < 1 { page = 1 } totalPages := int(total) / pageSize if int(total)%pageSize > 0 { totalPages++ } return &types.WikiPageListResponse{ Pages: pages, Total: total, Page: page, PageSize: pageSize, TotalPages: totalPages, }, nil } // DeletePage soft-deletes a wiki page func (s *wikiPageService) DeletePage(ctx context.Context, kbID string, slug string) error { page, err := s.repo.GetBySlug(ctx, kbID, slug) if err != nil { return err } // Remove inbound link references from pages this page links to s.removeInLinks(ctx, kbID, slug, page.OutLinks) // Delete the page if err := s.repo.Delete(ctx, kbID, slug); err != nil { return err } // Drop the snapshot history too: the page is gone from every read path, // so its revisions are unreachable rows holding full content bodies. // Best-effort — the page is already deleted and cannot be rolled back. if err := s.repo.DeleteRevisionsByPage(ctx, page.ID); err != nil { logger.Warnf(ctx, "delete wiki page revisions for %s failed: %v", page.ID, err) } // Delete synced chunk s.deleteChunkForPage(ctx, page) return nil } // GetIndex returns the index page for a knowledge base func (s *wikiPageService) GetIndex(ctx context.Context, kbID string) (*types.WikiPage, error) { page, err := s.repo.GetBySlug(ctx, kbID, "index") if err != nil { if errors.Is(err, repository.ErrWikiPageNotFound) { // Create default index page return s.createDefaultPage(ctx, kbID, "index", "Index", types.WikiPageTypeIndex, "# Wiki Index\n\nThis is the index page. It will be automatically updated as pages are added.\n") } return nil, err } return page, nil } // wikiIndexContentPageTypes enumerates the page types that make up a wiki's // user-visible directory. The index page is excluded; any // LLM-created type we do not recognize surfaces under a generic "other" // bucket. var wikiIndexContentPageTypes = []string{ types.WikiPageTypeSummary, types.WikiPageTypeEntity, types.WikiPageTypeConcept, types.WikiPageTypeSynthesis, types.WikiPageTypeComparison, } // GetIndexView builds the structured index response without ever // materializing a multi-MB directory markdown string. Intro is read from // the index wiki_page row (which now carries only intro text — see // rebuildIndexPage). Each requested page_type is paginated independently // with ListByTypeLight so reads stay O(page_size) rather than O(total // pages in the KB). // // `pageTypes` narrows which groups to include; empty = all content types. // `limit` is the per-group window size (defaults to 50, capped at 200). // `cursor` is an opaque offset string; currently we use the stringified // offset so clients can resume where they left off. Because different // page_types paginate independently, `cursor` applies uniformly to every // group — if the caller wants per-group cursors it should request one // type at a time via `pageTypes`. That simplifies the wire format and // matches the frontend's tabbed UX. func (s *wikiPageService) GetIndexView( ctx context.Context, kbID string, pageTypes []string, limit int, cursor string, ) (*types.WikiIndexResponse, error) { indexPage, err := s.GetIndex(ctx, kbID) if err != nil { return nil, fmt.Errorf("load index page: %w", err) } if limit <= 0 { limit = 50 } if limit > 200 { limit = 200 } offset := 0 if cursor != "" { v, parseErr := strconv.Atoi(cursor) if parseErr != nil || v < 0 { return nil, fmt.Errorf("invalid cursor %q", cursor) } offset = v } // Default to every known content type when the caller passes no // filter. Any unknown request-time type is passed through verbatim so // future page types (declared in types/wiki_page.go) start showing // up in the index the moment the LLM starts creating them, without a // handler change. selected := pageTypes if len(selected) == 0 { selected = append([]string{}, wikiIndexContentPageTypes...) } groups := make([]types.WikiIndexGroup, 0, len(selected)) for _, pt := range selected { entries, total, listErr := s.repo.ListByTypeLight(ctx, kbID, pt, limit, offset) if listErr != nil { return nil, fmt.Errorf("list %s pages: %w", pt, listErr) } if entries == nil { entries = []types.WikiIndexEntry{} } for i := range entries { normalizeWikiIndexEntryHierarchy(&entries[i], pt) } next := "" // Only emit a cursor when a full page was returned AND more rows // remain past `offset + limit`. A short page or one that exactly // consumed the remainder should signal end-of-feed. if len(entries) == limit && int64(offset+len(entries)) < total { next = strconv.Itoa(offset + limit) } groups = append(groups, types.WikiIndexGroup{ Type: pt, Total: total, Items: entries, NextCursor: next, }) } // The intro used to be stored on indexPage.Summary while // indexPage.Content held intro + directory markdown. After the // directory was lifted out of wiki_pages the content column holds // only the intro. Fall back to Summary for KBs that haven't been // re-ingested since the change so the response is never blank. intro := indexPage.Content if strings.TrimSpace(intro) == "" { intro = indexPage.Summary } return &types.WikiIndexResponse{ Intro: intro, Version: indexPage.Version, Groups: groups, }, nil } // GetGraph returns a slice of the wiki link graph for visualization. // // Two modes are supported: // // - WikiGraphModeOverview (default): returns the top `Limit` pages sorted // by link_count (in+out), plus every edge that connects two surviving // nodes. This is what the frontend fetches on the first graph open — // 4万-page wikis would otherwise ship ~30MB of JSON and crash the // browser trying to render 100k SVG elements. // // - WikiGraphModeEgo: returns the BFS neighborhood of `Center` up to // `Depth` undirected hops, capped at `Limit` total nodes. The // frontend uses this to drill down when the user clicks / searches a // node in the overview. // // `Types` is an optional page_type allow-list applied to both the candidate // node set and (in ego mode) the frontier expansion. Leaving it empty means // no type filter. // // `Limit <= 0` disables the cap entirely and is reserved for internal // callers like the lint service that need to walk every page. The HTTP // handler always clamps Limit into a safe range so external traffic can // never opt out of truncation. // // Implementation note: pages are still fetched via repo.ListAll. At 4万 // pages that's ~10MB of rows + deserialization, which is already on the // expensive side but still tractable and keeps the repository interface // unchanged. Pushing the filter/top-N down into SQL is a follow-up step // (cache layer + DB-side projection) — see CLAUDE.md plan. func (s *wikiPageService) GetGraph(ctx context.Context, req *types.WikiGraphRequest) (*types.WikiGraphData, error) { if req == nil { return nil, errors.New("wiki graph request is required") } pages, err := s.repo.ListAll(ctx, req.KnowledgeBaseID) if err != nil { return nil, err } return computeGraphSubset(pages, req) } // computeGraphSubset is the pure I/O-free core of GetGraph. It takes the // full page list and a request description and returns the subgraph the // caller asked for. Extracted from GetGraph so tests can exercise the // mode/limit/type-filter behavior without plumbing a full repository mock. func computeGraphSubset(pages []*types.WikiPage, req *types.WikiGraphRequest) (*types.WikiGraphData, error) { mode := req.Mode if mode == "" { mode = types.WikiGraphModeOverview } // Pre-compute link_count and the type allow-list used for candidate // filtering. We keep the full page list around so ego mode can still // traverse through neighbors whose type is in the allow-list. typeAllow := make(map[string]bool, len(req.Types)) for _, t := range req.Types { if t != "" { typeAllow[t] = true } } hasTypeFilter := len(typeAllow) > 0 familiarSet := make(map[string]struct{}, len(req.FamiliarKnowledgeIDs)) for _, id := range req.FamiliarKnowledgeIDs { if id = strings.TrimSpace(id); id != "" { familiarSet[id] = struct{}{} } } pageBySlug := make(map[string]*types.WikiPage, len(pages)) linkCount := make(map[string]int, len(pages)) for _, p := range pages { pageBySlug[p.Slug] = p linkCount[p.Slug] = len(p.InLinks) + len(p.OutLinks) } // Select the node slug set for the requested slice. var selected map[string]struct{} switch mode { case types.WikiGraphModeEgo: if req.Center == "" { return nil, errors.New("ego graph requires a center slug") } if _, ok := pageBySlug[req.Center]; !ok { return nil, fmt.Errorf("ego center slug %q not found", req.Center) } depth := req.Depth if depth < 1 { depth = 1 } selected = bfsEgoSlugs(pageBySlug, req.Center, depth, typeAllow, req.Limit) default: // overview: keep only type-allowed candidates, sort by link_count desc, cap. candidates := make([]*types.WikiPage, 0, len(pages)) for _, p := range pages { if hasTypeFilter && !typeAllow[p.PageType] { continue } candidates = append(candidates, p) } sort.SliceStable(candidates, func(i, j int) bool { li := linkCount[candidates[i].Slug] lj := linkCount[candidates[j].Slug] if li != lj { return li > lj } // Stable tiebreaker keeps the API deterministic between calls. return candidates[i].Slug < candidates[j].Slug }) if req.Limit > 0 && len(candidates) > req.Limit { candidates = candidates[:req.Limit] } selected = make(map[string]struct{}, len(candidates)) for _, p := range candidates { selected[p.Slug] = struct{}{} } } // Build nodes from the selected set. nodes := make([]types.WikiGraphNode, 0, len(selected)) for slug := range selected { p := pageBySlug[slug] nodes = append(nodes, types.WikiGraphNode{ Slug: p.Slug, Title: p.Title, PageType: p.PageType, LinkCount: linkCount[slug], Familiar: p.BuiltFrom(familiarSet), }) } // Deterministic node ordering — the map iteration above is random. sort.Slice(nodes, func(i, j int) bool { if nodes[i].LinkCount != nodes[j].LinkCount { return nodes[i].LinkCount > nodes[j].LinkCount } return nodes[i].Slug < nodes[j].Slug }) // Build edges, keeping only edges whose endpoints both survived selection. var edges []types.WikiGraphEdge for _, p := range pages { if _, ok := selected[p.Slug]; !ok { continue } for _, target := range p.OutLinks { if _, ok := selected[target]; !ok { continue } edges = append(edges, types.WikiGraphEdge{ Source: p.Slug, Target: target, }) } } // total is the count of candidate nodes before truncation — i.e. the // population the frontend would need to fetch if it asked for the // whole graph. For overview this respects the type filter; for ego // it is the total KB page count (the user still sees "X of Y" based // on the full wiki, not a filtered denominator). total := len(pages) if mode == types.WikiGraphModeOverview && hasTypeFilter { total = 0 for _, p := range pages { if typeAllow[p.PageType] { total++ } } } meta := types.WikiGraphMeta{ Mode: mode, Total: total, Returned: len(nodes), Truncated: len(nodes) < total, } for _, n := range nodes { if n.Familiar { meta.FamiliarCount++ } } if mode == types.WikiGraphModeEgo { meta.Center = req.Center meta.Depth = req.Depth if meta.Depth < 1 { meta.Depth = 1 } } return &types.WikiGraphData{ Nodes: nodes, Edges: edges, Meta: meta, }, nil } // bfsEgoSlugs computes the undirected BFS neighborhood of `center` up to // `depth` hops using both inbound and outbound links. Type-filtered pages // are excluded from the result but are also NOT traversed through — so a // filter that hides "index" pages will not leak the whole wiki via the // index. The caller guarantees center exists in pageBySlug. func bfsEgoSlugs( pageBySlug map[string]*types.WikiPage, center string, depth int, typeAllow map[string]bool, limit int, ) map[string]struct{} { hasTypeFilter := len(typeAllow) > 0 centerPage, ok := pageBySlug[center] if !ok { return map[string]struct{}{} } // If the center itself fails the type filter we honor the filter and // return an empty set — the handler will surface Returned=0. if hasTypeFilter && !typeAllow[centerPage.PageType] { return map[string]struct{}{} } visited := map[string]struct{}{center: {}} frontier := []string{center} for hop := 0; hop < depth; hop++ { if limit > 0 && len(visited) >= limit { break } next := make([]string, 0, len(frontier)) for _, slug := range frontier { p, ok := pageBySlug[slug] if !ok { continue } neighbors := make([]string, 0, len(p.OutLinks)+len(p.InLinks)) neighbors = append(neighbors, p.OutLinks...) neighbors = append(neighbors, p.InLinks...) for _, nb := range neighbors { if _, seen := visited[nb]; seen { continue } np, exists := pageBySlug[nb] if !exists { continue } if hasTypeFilter && !typeAllow[np.PageType] { continue } visited[nb] = struct{}{} next = append(next, nb) if limit > 0 && len(visited) >= limit { break } } if limit > 0 && len(visited) >= limit { break } } frontier = next if len(frontier) == 0 { break } } return visited } // GetStats returns aggregate statistics about the wiki func (s *wikiPageService) GetStats(ctx context.Context, kbID string) (*types.WikiStats, error) { counts, err := s.repo.CountByType(ctx, kbID) if err != nil { return nil, err } var total int64 for _, c := range counts { total += c } orphans, err := s.repo.CountOrphans(ctx, kbID) if err != nil { return nil, err } // Count total links pages, err := s.repo.ListAll(ctx, kbID) if err != nil { return nil, err } var totalLinks int64 for _, p := range pages { totalLinks += int64(len(p.OutLinks)) } // Get recent updates (last 10) listReq := &types.WikiPageListRequest{ KnowledgeBaseID: kbID, Page: 1, PageSize: 10, SortBy: "updated_at", SortOrder: "desc", } recentPages, _, err := s.repo.List(ctx, listReq) if err != nil { return nil, err } var pendingTasks int64 var pendingIssues int64 var isActive bool if s.taskPendingRepo != nil { // Pending wiki ingest ops live in task_pending_ops keyed by // (task_type="wiki:ingest", scope="knowledge_base", scope_id=kbID). pendingTasks, _ = s.taskPendingRepo.PendingCount(ctx, wikiTaskType, wikiTaskScope, kbID) } if s.redisClient != nil { // The "active batch in progress" flag is still a Redis-only // short-lived signal (per-process lock with TTL renew); not // worth migrating since it carries no durable state. activeFlag, _ := s.redisClient.Exists(ctx, "wiki:active:"+kbID).Result() isActive = activeFlag > 0 } issues, _ := s.ListIssues(ctx, kbID, "", "pending") pendingIssues = int64(len(issues)) return &types.WikiStats{ TotalPages: total, PagesByType: counts, TotalLinks: totalLinks, OrphanCount: orphans, RecentUpdates: recentPages, PendingTasks: pendingTasks, PendingIssues: pendingIssues, IsActive: isActive, }, nil } // RebuildLinks re-parses all pages and rebuilds bidirectional link references func (s *wikiPageService) RebuildLinks(ctx context.Context, kbID string) error { pages, err := s.repo.ListAll(ctx, kbID) if err != nil { return err } // Build slug-to-page map pageMap := make(map[string]*types.WikiPage) for _, p := range pages { pageMap[p.Slug] = p } // Clear all inbound links first for _, p := range pages { p.InLinks = types.StringArray{} } // Re-parse outbound links and rebuild inbound links for _, p := range pages { p.OutLinks = s.parseOutLinks(p.Content) for _, target := range p.OutLinks { if tp, exists := pageMap[target]; exists { tp.InLinks = append(tp.InLinks, p.Slug) } } } // Save all pages (link rebuild is metadata-only, no version bump) for _, p := range pages { if err := s.UpdatePageMeta(ctx, p); err != nil { logger.Warnf(ctx, "wiki: failed to update links for page %s: %v", p.Slug, err) } } return nil } // ListAllPages retrieves all non-archived wiki pages without pagination. func (s *wikiPageService) ListAllPages(ctx context.Context, kbID string) ([]*types.WikiPage, error) { return s.repo.ListAll(ctx, kbID) } // ListByType returns every wiki page of a given type for a KB. Exposed so // callers like intro regeneration can load only the page type they need // (summaries) instead of paying for the full ListAll scan. func (s *wikiPageService) ListByType(ctx context.Context, kbID string, pageType string) ([]*types.WikiPage, error) { return s.repo.ListByType(ctx, kbID, pageType) } // ListPagesBySourceRef exposes the repository's source-ref lookup so higher // layers (delete flow, retract reconciliation) can re-query the current wiki // state without depending on a stale caller-captured slug list. func (s *wikiPageService) ListPagesBySourceRef(ctx context.Context, kbID string, knowledgeID string) ([]*types.WikiPage, error) { return s.repo.ListBySourceRef(ctx, kbID, knowledgeID) } // ListSlugsBySourceRef returns just the slugs of pages that cite the given // knowledge id. Backed by the source_refs GIN index added in migration // 000041 — the wiki ingest pipeline uses it as a cheap "before" snapshot // when reconciling old vs new extraction sets. func (s *wikiPageService) ListSlugsBySourceRef(ctx context.Context, kbID string, knowledgeID string) ([]string, error) { return s.repo.ListSlugsBySourceRef(ctx, kbID, knowledgeID) } // ListBySlugs is the lazy fetcher used by wiki ingest's batch context. // Returns lightweight projections (no content / source_refs / chunk_refs) // for the requested slugs, in a single IN query. Used in place of the // pre-batch ListAllPages dump that historically pulled hundreds of MB // for KBs in the tens of thousands of pages. func (s *wikiPageService) ListBySlugs(ctx context.Context, kbID string, slugs []string) (map[string]*types.WikiPageLite, error) { return s.repo.ListBySlugs(ctx, kbID, slugs) } // ListSummariesByKnowledgeIDs is the lazy fetcher for the retract / // reparse branches of reduceSlugUpdates. Returns the content of each // surviving summary page keyed by its source knowledge id. func (s *wikiPageService) ListSummariesByKnowledgeIDs(ctx context.Context, kbID string, kids []string) (map[string]string, error) { return s.repo.ListSummariesByKnowledgeIDs(ctx, kbID, kids) } // ExistsSlugs reports which of the given slugs are live (non-archived, // non-deleted) in the KB. Used by cleanDeadLinks to validate out-link // targets before stripping them. func (s *wikiPageService) ExistsSlugs(ctx context.Context, kbID string, slugs []string) (map[string]bool, error) { return s.repo.ExistsSlugs(ctx, kbID, slugs) } // ListAllSlugs returns every non-archived slug in the KB. Used by lint // to compute the live-slug set without paying for ListAll's full row // materialization. func (s *wikiPageService) ListAllSlugs(ctx context.Context, kbID string) ([]string, error) { return s.repo.ListAllSlugs(ctx, kbID) } // ListPagesCursor is the lint-side cursor pagination over wiki_pages. func (s *wikiPageService) ListPagesCursor(ctx context.Context, kbID string, cursor string, limit int) ([]*types.WikiPage, string, error) { return s.repo.ListPagesCursor(ctx, kbID, cursor, limit) } // ListByTypeRecent caps the page count for first-time index intro // generation so the LLM prompt stays bounded on large KBs. func (s *wikiPageService) ListByTypeRecent(ctx context.Context, kbID string, pageType string, limit int) ([]types.WikiIndexEntry, error) { return s.repo.ListByTypeRecent(ctx, kbID, pageType, limit) } // FindSimilarPages performs a pg_trgm similarity search; used by the // dedup pre-filter to surface candidate merge targets. func (s *wikiPageService) FindSimilarPages(ctx context.Context, kbID string, query string, pageTypes []string, limit int) ([]*types.WikiPageLite, error) { return s.repo.FindSimilarPages(ctx, kbID, query, pageTypes, limit) } // FindPagesByNormalizedTitle looks up exact same-type title identities for // wiki ingest, independent of the trigram top-K used for semantic dedup. func (s *wikiPageService) FindPagesByNormalizedTitle(ctx context.Context, kbID, pageType, identity string) ([]*types.WikiPageLite, error) { return s.repo.FindPagesByNormalizedTitle(ctx, kbID, pageType, identity) } // FindPagesByNormalizedTitles looks up several normalized title identities // in one query so wiki ingest does not seq-scan once per extracted item. func (s *wikiPageService) FindPagesByNormalizedTitles(ctx context.Context, kbID, pageType string, identities []string) ([]*types.WikiPageLite, error) { return s.repo.FindPagesByNormalizedTitles(ctx, kbID, pageType, identities) } // ListDistinctCategoryPaths returns the existing wiki folder paths. Used by // wiki ingest's taxonomy planner to ground folder reuse. func (s *wikiPageService) ListDistinctCategoryPaths(ctx context.Context, kbID string, maxPaths int) ([][]string, error) { return s.repo.ListDistinctCategoryPaths(ctx, kbID, maxPaths) } // CountByType is a service-layer pass-through over the repo. Used by // the index intro path to frame the LLM prompt's "showing N of M" hint. func (s *wikiPageService) CountByType(ctx context.Context, kbID string) (map[string]int64, error) { return s.repo.CountByType(ctx, kbID) } // SearchPages performs full-text search over wiki pages func (s *wikiPageService) SearchPages(ctx context.Context, kbID string, query string, limit int) ([]*types.WikiPage, error) { return s.repo.Search(ctx, kbID, query, limit) } // --- Internal helpers --- // parseOutLinks extracts [[wiki-link]] slugs from markdown content func (s *wikiPageService) parseOutLinks(content string) types.StringArray { matches := wikiLinkRegex.FindAllStringSubmatch(content, -1) seen := make(map[string]bool) var links types.StringArray for _, match := range matches { if len(match) > 1 { slug := strings.TrimSpace(match[1]) // Handle [[slug|display name]] format — slug is the first part if parts := strings.SplitN(slug, "|", 2); len(parts) == 2 { slug = strings.TrimSpace(parts[0]) } slug = normalizeSlug(slug) if slug != "" && !seen[slug] { seen[slug] = true links = append(links, slug) } } } return links } // normalizeSlug normalizes a wiki link slug func normalizeSlug(slug string) string { slug = strings.ToLower(strings.TrimSpace(slug)) slug = strings.ReplaceAll(slug, " ", "-") return slug } // slugNamespace returns the prefix of a slug up to (but excluding) the first // '/', e.g. "summary/abc" -> "summary". Slugs without a '/' map to "". func slugNamespace(slug string) string { if i := strings.IndexByte(slug, '/'); i >= 0 { return slug[:i] } return "" } // rewriteDeadWikiLinks walks every [[slug]] / [[slug|display]] occurrence in // content and lets `resolve` decide, per link, whether to rewrite the slug. // `resolve` receives the normalized slug and its display text and returns the // replacement slug plus true to rewrite, or ("", false) to leave the link // untouched. Display text is preserved verbatim. This is a pure helper — the // resolution policy lives entirely in the callback. func rewriteDeadWikiLinks(content string, resolve func(normSlug, display string) (string, bool)) (string, bool) { changed := false out := wikiLinkRegex.ReplaceAllStringFunc(content, func(match string) string { inner := match[2 : len(match)-2] rawSlug := inner display := "" if parts := strings.SplitN(inner, "|", 2); len(parts) == 2 { rawSlug = parts[0] display = strings.TrimSpace(parts[1]) } norm := normalizeSlug(rawSlug) if norm == "" { return match } newSlug, ok := resolve(norm, display) if !ok || newSlug == "" || newSlug == norm { return match } changed = true if display != "" { return "[[" + newSlug + "|" + display + "]]" } return "[[" + newSlug + "]]" }) return out, changed } // RepairContentLinks rewrites [[slug]] / [[slug|display]] references in // `content` whose target does not exist in the KB but is almost certainly a // mangled form of a real page. The canonical case: an LLM re-typed a summary // page's UUID-based slug and inserted/dropped a hex digit // (summary/…06fb5d5b5b5e → summary/…06fb14d5b14b14e), producing a link that // 404s and can never be recovered by exact lookup. // // Unlike stripDeadWikiLinks (the ingest cleanup pass), this method is // REWRITE-ONLY: a dead link is corrected only when a confident live candidate // exists; otherwise it is left completely untouched. It NEVER strips a link to // plain text, so it is safe on any write path — including writes whose targets // legitimately do not exist yet (those simply stay as-is until they do). // // The candidate pool for each dead link is scoped to live slugs that share the // same namespace prefix (a dead `summary/` only resolves against live // `summary/*` slugs). Scoping keeps the bigram-similarity lever safe: distinct // high-entropy UUIDs in one namespace don't collide, while a one-character // mangle stays comfortably above threshold against its true source. // // Returns the possibly-updated content and whether any rewrite happened. // Errors are only returned for hard repo failures; callers may treat repair as // best-effort and ignore them. func (s *wikiPageService) RepairContentLinks( ctx context.Context, kbID, selfSlug, content string, ) (string, bool, error) { if strings.TrimSpace(content) == "" { return content, false, nil } outLinks := s.parseOutLinks(content) if len(outLinks) == 0 { return content, false, nil } existMap, err := s.repo.ExistsSlugs(ctx, kbID, outLinks) if err != nil { return content, false, err } deadPrefixes := make(map[string]struct{}) for _, l := range outLinks { if l == selfSlug || existMap[l] { continue } deadPrefixes[slugNamespace(l)] = struct{}{} } if len(deadPrefixes) == 0 { return content, false, nil } allSlugs, err := s.repo.ListAllSlugs(ctx, kbID) if err != nil { return content, false, err } liveByPrefix := make(map[string]map[string]struct{}) var candidateSlugs []string for _, sl := range allSlugs { ns := slugNamespace(sl) if _, want := deadPrefixes[ns]; !want { continue } set, ok := liveByPrefix[ns] if !ok { set = make(map[string]struct{}) liveByPrefix[ns] = set } set[sl] = struct{}{} candidateSlugs = append(candidateSlugs, sl) } if len(candidateSlugs) == 0 { return content, false, nil } // Build a title -> slug reverse lookup for candidate pages so the // display-text lever (the safest, most precise one) can fire. Bounded to // the relevant namespaces so this stays cheap even on large KBs. titleToSlug := make(map[string]string) if lites, lerr := s.repo.ListBySlugs(ctx, kbID, candidateSlugs); lerr == nil { for _, lp := range lites { if lp != nil && lp.Title != "" { titleToSlug[lp.Title] = lp.Slug } } } resolveCache := make(map[string]string) newContent, changed := rewriteDeadWikiLinks(content, func(norm, display string) (string, bool) { if norm == selfSlug || existMap[norm] { return "", false } key := norm + "\x00" + display if cached, ok := resolveCache[key]; ok { return cached, cached != "" } resolved, ok := resolveDeadSlug(norm, display, liveByPrefix[slugNamespace(norm)], titleToSlug) if !ok || resolved == norm { resolveCache[key] = "" return "", false } resolveCache[key] = resolved return resolved, true }) return newContent, changed, nil } // updateInLinks adds the source slug to the in_links of target pages func (s *wikiPageService) updateInLinks(ctx context.Context, kbID string, sourceSlug string, targets types.StringArray) { for _, targetSlug := range targets { targetPage, err := s.repo.GetBySlug(ctx, kbID, targetSlug) if err != nil { continue // target page may not exist yet } if !containsString(targetPage.InLinks, sourceSlug) { writeCtx := types.WithWikiPageWriteGuard(ctx, targetPage) targetPage.InLinks = append(targetPage.InLinks, sourceSlug) if err := s.UpdatePageMeta(writeCtx, targetPage); err != nil { logger.Warnf(ctx, "wiki: failed to update in_links for %s: %v", targetSlug, err) } } } } // removeInLinks removes the source slug from the in_links of target pages func (s *wikiPageService) removeInLinks(ctx context.Context, kbID string, sourceSlug string, targets types.StringArray) { for _, targetSlug := range targets { targetPage, err := s.repo.GetBySlug(ctx, kbID, targetSlug) if err != nil { continue } newInLinks := removeString(targetPage.InLinks, sourceSlug) if len(newInLinks) != len(targetPage.InLinks) { writeCtx := types.WithWikiPageWriteGuard(ctx, targetPage) targetPage.InLinks = newInLinks if err := s.UpdatePageMeta(writeCtx, targetPage); err != nil { logger.Warnf(ctx, "wiki: failed to update in_links for %s: %v", targetSlug, err) } } } } // deleteChunkForPage removes the synced chunk for a wiki page. Chunk sync is // optional wiring, so a service built without a chunk repository just skips // it rather than taking the delete down with it. func (s *wikiPageService) deleteChunkForPage(ctx context.Context, page *types.WikiPage) { if s.chunkRepo == nil { return } chunkID := "wp-" + page.ID if err := s.chunkRepo.DeleteChunk(ctx, page.TenantID, chunkID); err != nil { logger.Warnf(ctx, "wiki: failed to delete chunk for page %s: %v", page.Slug, err) } } // createDefaultPage creates the default index page. func (s *wikiPageService) createDefaultPage(ctx context.Context, kbID string, slug string, title string, pageType string, content string) (*types.WikiPage, error) { // Get KB to get tenant ID kb, err := s.kbService.GetKnowledgeBaseByIDOnly(ctx, kbID) if err != nil { return nil, fmt.Errorf("get knowledge base: %w", err) } page := &types.WikiPage{ ID: uuid.New().String(), TenantID: kb.TenantID, KnowledgeBaseID: kbID, Slug: slug, Title: title, PageType: pageType, Status: types.WikiPageStatusPublished, Content: content, Summary: title, Version: 1, } normalizeWikiHierarchy(page) if err := s.repo.Create(ctx, page); err != nil { return nil, fmt.Errorf("create default %s page: %w", slug, err) } return page, nil } func normalizeWikiHierarchy(page *types.WikiPage) { if page == nil { return } page.ParentSlug = strings.TrimSpace(page.ParentSlug) cleanPath := types.StringArray(types.CleanWikiCategoryPath(page.CategoryPath)) page.CategoryPath = cleanPath page.Depth = len(cleanPath) display := strings.TrimSpace(page.Title) if display == "" { display = strings.TrimSpace(page.Slug) } page.WikiPath = buildWikiPath(page.PageType, cleanPath, display) } func normalizeWikiIndexEntryHierarchy(entry *types.WikiIndexEntry, pageType string) { if entry == nil { return } cleanPath := types.StringArray(types.CleanWikiCategoryPath(entry.CategoryPath)) entry.CategoryPath = cleanPath entry.Depth = len(cleanPath) display := strings.TrimSpace(entry.Title) if display == "" { display = strings.TrimSpace(entry.Slug) } entry.WikiPath = buildWikiPath(pageType, cleanPath, display) } // buildWikiPath assembles the normalized, sortable "page_type/cat.../title" // breadcrumb used for directory ordering. Empty segments are skipped. func buildWikiPath(pageType string, categoryPath []string, display string) string { parts := make([]string, 0, len(categoryPath)+2) if pt := strings.TrimSpace(pageType); pt != "" { parts = append(parts, pt) } parts = append(parts, categoryPath...) if display != "" { parts = append(parts, display) } return strings.Join(parts, "/") } // containsString checks if a string slice contains a given string func containsString(slice []string, s string) bool { for _, v := range slice { if v == s { return true } } return false } // removeString removes a string from a slice func removeString(slice []string, s string) types.StringArray { result := make(types.StringArray, 0, len(slice)) for _, v := range slice { if v != s { result = append(result, v) } } return result } // CreateIssue logs a new issue for a wiki page func (s *wikiPageService) CreateIssue(ctx context.Context, issue *types.WikiPageIssue) (*types.WikiPageIssue, error) { if issue.ID == "" { issue.ID = uuid.New().String() } if err := s.repo.CreateIssue(ctx, issue); err != nil { return nil, fmt.Errorf("create wiki page issue: %w", err) } return issue, nil } // ListIssues retrieves issues for a knowledge base func (s *wikiPageService) ListIssues(ctx context.Context, kbID string, slug string, status string) ([]*types.WikiPageIssue, error) { return s.repo.ListIssues(ctx, kbID, slug, status) } // UpdateIssueStatus updates an issue's status func (s *wikiPageService) UpdateIssueStatus(ctx context.Context, issueID string, status string) error { return s.repo.UpdateIssueStatus(ctx, issueID, status) } // --- Folder tree (wiki_folders) --- // wikiFolderSegments splits a materialized folder path ("AI/RAG") into cleaned // segments. Empty/blank path yields nil (the wiki root). func wikiFolderSegments(path string) []string { if strings.TrimSpace(path) == "" { return nil } return types.CleanWikiCategoryPath(strings.Split(path, "/")) } // applyFolderToPage refreshes a page's derived category_path cache from its // authoritative FolderID. Root ("") clears the path. A folder id that does not // resolve is treated as a hard error so we never silently misplace a page. func (s *wikiPageService) applyFolderToPage(ctx context.Context, page *types.WikiPage) error { if page == nil { return nil } if strings.TrimSpace(page.FolderID) == "" { page.FolderID = "" page.CategoryPath = nil return nil } folder, err := s.repo.GetFolderByID(ctx, page.KnowledgeBaseID, page.FolderID) if err != nil { if errors.Is(err, repository.ErrWikiFolderNotFound) { return fmt.Errorf("wiki page references unknown folder %q", page.FolderID) } return fmt.Errorf("resolve page folder: %w", err) } page.CategoryPath = types.StringArray(wikiFolderSegments(folder.Path)) return nil } // GetFolder retrieves a single folder by id. func (s *wikiPageService) GetFolder(ctx context.Context, kbID string, id string) (*types.WikiFolder, error) { return s.repo.GetFolderByID(ctx, kbID, id) } // ListChildFolders returns the direct children of parentID for a tree view // scoped to pageTypes. PageCount is recursive (the folder's whole subtree) so // a parent reflects everything filed beneath it. A folder is shown when its // subtree holds a page matching pageTypes. Wholly-empty folders (no pages of // any type underneath) are only listed when multiple types are requested — // the merged knowledge view — so single-type tabs like summary do not surface // empty containers. func (s *wikiPageService) ListChildFolders( ctx context.Context, kbID string, parentID string, pageTypes []string, ) ([]types.WikiFolderNode, error) { all, err := s.repo.ListAllFolders(ctx, kbID) if err != nil { return nil, err } scopedDirect, err := s.repo.CountPagesByFolder(ctx, kbID, pageTypes) if err != nil { return nil, err } allDirect := scopedDirect if len(pageTypes) > 0 { allDirect, err = s.repo.CountPagesByFolder(ctx, kbID, nil) if err != nil { return nil, err } } recScoped := recursiveFolderCounts(all, scopedDirect) recAll := recursiveFolderCounts(all, allDirect) showEmptyFolders := len(pageTypes) > 1 // A folder belongs in this view if it (recursively) contains a page of the // requested types, or — only in the merged knowledge view — if it is a // completely empty container with no pages of any type underneath. relevant := func(id string) bool { if recScoped[id] > 0 { return true } if showEmptyFolders { return recAll[id] == 0 } return false } out := make([]types.WikiFolderNode, 0) for _, f := range all { if f.ParentID != parentID || !relevant(f.ID) { continue } hasChildren := false for _, g := range all { if g.ParentID == f.ID && relevant(g.ID) { hasChildren = true break } } out = append(out, types.WikiFolderNode{ WikiFolder: *f, PageCount: recScoped[f.ID], HasChildren: hasChildren, }) } return out, nil } // recursiveFolderCounts maps each folder id to the sum of `direct` page counts // over the folder and all of its descendants, using the materialized path so a // single pass over the (navigation-sized) folder set suffices. func recursiveFolderCounts(all []*types.WikiFolder, direct map[string]int64) map[string]int64 { res := make(map[string]int64, len(all)) for _, f := range all { sum := direct[f.ID] prefix := f.Path + "/" for _, g := range all { if g.ID != f.ID && strings.HasPrefix(g.Path, prefix) { sum += direct[g.ID] } } res[f.ID] = sum } return res } // validateFolderName trims and rejects blank names or names carrying directory // separators (a folder name is a single tree level). func validateFolderName(name string) (string, error) { name = strings.TrimSpace(name) if name == "" { return "", errors.New("folder name is required") } if strings.ContainsAny(name, "/||/") { return "", fmt.Errorf("folder name %q must not contain a path separator", name) } return name, nil } // CreateFolder creates a new empty folder under parentID. func (s *wikiPageService) CreateFolder( ctx context.Context, kbID string, tenantID uint64, parentID string, name string, ) (*types.WikiFolder, error) { name, err := validateFolderName(name) if err != nil { return nil, err } parentPath := "" depth := 1 if parentID != types.WikiFolderRootID { parent, err := s.repo.GetFolderByID(ctx, kbID, parentID) if err != nil { return nil, err } parentPath = parent.Path depth = parent.Depth + 1 } if _, err := s.repo.GetChildFolderByName(ctx, kbID, parentID, name); err == nil { return nil, repository.ErrWikiFolderConflict } else if !errors.Is(err, repository.ErrWikiFolderNotFound) { return nil, err } path := name if parentPath != "" { path = parentPath + "/" + name } now := time.Now() folder := &types.WikiFolder{ ID: uuid.New().String(), TenantID: tenantID, KnowledgeBaseID: kbID, ParentID: parentID, Name: name, Path: path, Depth: depth, CreatedAt: now, UpdatedAt: now, } if err := s.repo.CreateFolder(ctx, folder); err != nil { return nil, fmt.Errorf("create wiki folder: %w", err) } return folder, nil } // FindOrCreateFolderPath resolves a category path to a leaf folder id, creating // any missing intermediate folders along the way. Concurrency-safe against the // unique (kb, parent, name) constraint via a re-fetch on create conflict. func (s *wikiPageService) FindOrCreateFolderPath( ctx context.Context, kbID string, tenantID uint64, path []string, ) (string, []string, error) { clean := types.CleanWikiCategoryPath(path) if len(clean) == 0 { return types.WikiFolderRootID, nil, nil } parentID := types.WikiFolderRootID parentPath := "" for depth, name := range clean { child, err := s.repo.GetChildFolderByName(ctx, kbID, parentID, name) if err != nil { if !errors.Is(err, repository.ErrWikiFolderNotFound) { return "", nil, err } fp := name if parentPath != "" { fp = parentPath + "/" + name } now := time.Now() child = &types.WikiFolder{ ID: uuid.New().String(), TenantID: tenantID, KnowledgeBaseID: kbID, ParentID: parentID, Name: name, Path: fp, Depth: depth + 1, CreatedAt: now, UpdatedAt: now, } if cerr := s.repo.CreateFolder(ctx, child); cerr != nil { // Lost a create race (or unique violation): the sibling must // now exist — re-fetch it rather than failing the whole plan. child, err = s.repo.GetChildFolderByName(ctx, kbID, parentID, name) if err != nil { return "", nil, fmt.Errorf("create wiki folder %q: %w", fp, cerr) } } } parentID = child.ID parentPath = child.Path } return parentID, clean, nil } // MovePage relocates a page into folderID ("" = root) and refreshes its cached // category path. Bookkeeping-only write (no version bump). func (s *wikiPageService) MovePage( ctx context.Context, kbID string, slug string, folderID string, ) (*types.WikiPage, error) { page, err := s.repo.GetBySlug(ctx, kbID, slug) if err != nil { return nil, err } page.FolderID = strings.TrimSpace(folderID) if err := s.applyFolderToPage(ctx, page); err != nil { return nil, err } if err := s.UpdatePageMeta(ctx, page); err != nil { return nil, fmt.Errorf("move wiki page: %w", err) } return page, nil } // RenameOrMoveFolder renames and/or reparents a folder, then recomputes the // materialized path/depth of the entire subtree and the cached category path of // every page underneath. Guards against cycles (moving a folder into itself or // one of its descendants) and sibling name collisions. func (s *wikiPageService) RenameOrMoveFolder( ctx context.Context, kbID string, id string, newName string, newParentID string, moveParent bool, ) (*types.WikiFolder, error) { folder, err := s.repo.GetFolderByID(ctx, kbID, id) if err != nil { return nil, err } name := folder.Name if strings.TrimSpace(newName) != "" { if name, err = validateFolderName(newName); err != nil { return nil, err } } targetParent := folder.ParentID if moveParent { targetParent = newParentID } parentPath := "" depthBase := 0 if targetParent != types.WikiFolderRootID { if targetParent == folder.ID { return nil, errors.New("cannot move a folder into itself") } parent, err := s.repo.GetFolderByID(ctx, kbID, targetParent) if err != nil { return nil, err } if parent.Path == folder.Path || strings.HasPrefix(parent.Path, folder.Path+"/") { return nil, errors.New("cannot move a folder into its own descendant") } parentPath = parent.Path depthBase = parent.Depth } if existing, err := s.repo.GetChildFolderByName(ctx, kbID, targetParent, name); err == nil { if existing.ID != folder.ID { return nil, repository.ErrWikiFolderConflict } } else if !errors.Is(err, repository.ErrWikiFolderNotFound) { return nil, err } oldPath := folder.Path newPath := name if parentPath != "" { newPath = parentPath + "/" + name } if newPath == oldPath && targetParent == folder.ParentID { return folder, nil // no-op } all, err := s.repo.ListAllFolders(ctx, kbID) if err != nil { return nil, err } now := time.Now() affected := make([]string, 0) var updated *types.WikiFolder for _, f := range all { switch { case f.ID == folder.ID: f.ParentID = targetParent f.Name = name f.Path = newPath f.Depth = depthBase + 1 case strings.HasPrefix(f.Path, oldPath+"/"): f.Path = newPath + f.Path[len(oldPath):] f.Depth = len(wikiFolderSegments(f.Path)) default: continue } f.UpdatedAt = now if err := s.repo.UpdateFolder(ctx, f); err != nil { return nil, err } affected = append(affected, f.ID) if f.ID == folder.ID { updated = f } } if err := s.recomputePagesForFolders(ctx, kbID, affected); err != nil { return nil, err } if updated == nil { updated = folder } return updated, nil } // recomputePagesForFolders refreshes the cached category_path/wiki_path/depth of // every page filed under any of the given folder ids (used after a folder // subtree is moved/renamed). Bookkeeping-only writes (no version bump). func (s *wikiPageService) recomputePagesForFolders(ctx context.Context, kbID string, folderIDs []string) error { if len(folderIDs) == 0 { return nil } pages, err := s.repo.ListPagesByFolderIDs(ctx, kbID, folderIDs) if err != nil { return err } for _, page := range pages { if err := s.applyFolderToPage(ctx, page); err != nil { return err } if err := s.UpdatePageMeta(ctx, page); err != nil { logger.Warnf(ctx, "wiki: recompute folder path for page %s failed: %v", page.Slug, err) } } return nil } // DeleteFolder removes a folder that has no pages and no child folders. The UI // must relocate contents first; this keeps deletion non-destructive. func (s *wikiPageService) DeleteFolder(ctx context.Context, kbID string, id string) error { if _, err := s.repo.GetFolderByID(ctx, kbID, id); err != nil { return err } children, err := s.repo.ListChildFolders(ctx, kbID, id) if err != nil { return err } if len(children) > 0 { return repository.ErrWikiFolderNotEmpty } pages, err := s.repo.ListPagesByFolderIDs(ctx, kbID, []string{id}) if err != nil { return err } if len(pages) > 0 { return repository.ErrWikiFolderNotEmpty } return s.repo.DeleteFolder(ctx, kbID, id) } // PruneEmptyFolderChains removes folders that became empty after retracting a // document, followed by any ancestors made empty by those removals. It only // considers the supplied folder chains, so intentionally-empty folders // elsewhere in the wiki are preserved. Callers must wait for the KB's ingest // queue to drain before invoking this method: taxonomy planning creates a // folder before reduce writes the page that will reference it. func (s *wikiPageService) PruneEmptyFolderChains( ctx context.Context, kbID string, folderIDs []string, ) ([]string, error) { if len(folderIDs) == 0 { return nil, nil } all, err := s.repo.ListAllFolders(ctx, kbID) if err != nil { return nil, err } byID := make(map[string]*types.WikiFolder, len(all)) for _, folder := range all { if folder != nil { byID[folder.ID] = folder } } candidates := make(map[string]*types.WikiFolder) for _, id := range folderIDs { seen := make(map[string]struct{}) for id != types.WikiFolderRootID { if _, cycle := seen[id]; cycle { break } seen[id] = struct{}{} folder := byID[id] if folder == nil { break } candidates[id] = folder id = folder.ParentID } } ordered := make([]*types.WikiFolder, 0, len(candidates)) for _, folder := range candidates { ordered = append(ordered, folder) } sort.Slice(ordered, func(i, j int) bool { if ordered[i].Depth == ordered[j].Depth { return ordered[i].Path > ordered[j].Path } return ordered[i].Depth > ordered[j].Depth }) deleted := make([]string, 0, len(ordered)) for _, folder := range ordered { children, err := s.repo.ListChildFolders(ctx, kbID, folder.ID) if err != nil { return deleted, err } if len(children) > 0 { continue } pages, err := s.repo.ListPagesByFolderIDs(ctx, kbID, []string{folder.ID}) if err != nil { return deleted, err } if len(pages) > 0 { continue } if err := s.repo.DeleteFolder(ctx, kbID, folder.ID); err != nil { if errors.Is(err, repository.ErrWikiFolderNotFound) || errors.Is(err, repository.ErrWikiFolderNotEmpty) { continue } return deleted, err } deleted = append(deleted, folder.ID) } return deleted, nil } // InjectCrossLinks scans affected pages and injects [[wiki-links]] for mentions // of other wiki page titles in the content. Pure text replacement, no LLM call. // Shares the linkifyContent helper with the ingest pipeline so both paths honor // the same code-block / existing-link / word-boundary rules. func (s *wikiPageService) InjectCrossLinks(ctx context.Context, kbID string, affectedSlugs []string) { allPages, err := s.ListAllPages(ctx, kbID) if err != nil || len(allPages) < 2 { return } refs := collectLinkRefs(allPages) if len(refs) == 0 { return } affectedSet := make(map[string]bool, len(affectedSlugs)) for _, slug := range affectedSlugs { affectedSet[slug] = true } var updated int for _, p := range allPages { if !affectedSet[p.Slug] { continue } if p.PageType == types.WikiPageTypeIndex { continue } newContent, changed := linkifyContent(p.Content, refs, p.Slug) if !changed { continue } p.Content = newContent if err := s.UpdateAutoLinkedContent(ctx, p); err != nil { logger.Warnf(ctx, "wiki: cross-link injection failed for %s: %v", p.Slug, err) continue } updated++ } if updated > 0 { logger.Infof(ctx, "wiki: injected cross-links in %d pages", updated) } } // RebuildIndexPage was historically called by agent write/rename tools to // refresh the index page's directory listing after a page mutation. // // The directory is no longer persisted in wiki_pages.content — it is // assembled on demand by GetIndexView from the lightweight ListByTypeLight // projection, so individual page writes don't need to redo O(N) string // concatenation and rewrite a multi-MB TEXT column anymore. Keeping the // method name lets existing agent tool call sites (wiki_write_page, // wiki_rename_page) compile unchanged; the body is now intentionally a // no-op. // // The intro that still lives on the index row is managed separately by // the ingest pipeline (see wikiIngestService.rebuildIndexPage) on batch // completion, which is where we actually have the LLM + change description // context needed to rewrite it. func (s *wikiPageService) RebuildIndexPage(ctx context.Context, kbID string) error { _ = ctx _ = kbID return nil }