Files
xinmi/internal/application/service/wiki_page.go
T
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

1917 lines
62 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/<uuid>` 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
}