Files
xinmi/internal/application/repository/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

1360 lines
44 KiB
Go

package repository
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ErrWikiPageNotFound is returned when a wiki page is not found
var ErrWikiPageNotFound = errors.New("wiki page not found")
// ErrWikiPageConflict is returned when an optimistic lock conflict is detected
var ErrWikiPageConflict = errors.New("wiki page version conflict")
// wikiPageRepository implements the WikiPageRepository interface
type wikiPageRepository struct {
db *gorm.DB
}
// NewWikiPageRepository creates a new wiki page repository
func NewWikiPageRepository(db *gorm.DB) interfaces.WikiPageRepository {
return &wikiPageRepository{db: db}
}
func (r *wikiPageRepository) wikiCategoryRankOrder() string {
if r.db != nil && r.db.Dialector != nil && r.db.Dialector.Name() == "sqlite" {
return "CASE WHEN COALESCE(json_array_length(category_path), 0) > 0 THEN 0 ELSE 1 END ASC"
}
return "CASE WHEN COALESCE(jsonb_array_length(category_path), 0) > 0 THEN 0 ELSE 1 END ASC"
}
func (r *wikiPageRepository) wikiEmptyInLinksPredicate() string {
if r.db != nil && r.db.Dialector != nil && r.db.Dialector.Name() == "sqlite" {
return "(in_links IS NULL OR json_array_length(in_links) = 0)"
}
return "(in_links IS NULL OR in_links = '[]'::JSONB)"
}
// Create inserts a new wiki page record
func (r *wikiPageRepository) Create(ctx context.Context, page *types.WikiPage) error {
return r.db.WithContext(ctx).Create(page).Error
}
// Update updates an existing wiki page record with optimistic locking.
// Increments version — use only for content changes visible to the user.
// The caller must set page.Version to the expected current version.
//
// The write goes through an explicit column map (not GORM's struct Updates)
// so cleared fields persist: struct Updates skips zero values, which used to
// make "empty the summary" silently not stick while the follow-up UpdateMeta
// map call *did* write empty status — one inconsistent half-update. The map
// covers every column UpdatePage mutates, so no UpdateMeta chaser is needed.
func (r *wikiPageRepository) Update(ctx context.Context, page *types.WikiPage) error {
return updateWikiPageRow(r.db.WithContext(ctx), page)
}
// UpdateWithRevision snapshots the version being superseded and applies the
// update atomically. Doing both in one transaction is what makes the history
// trustworthy: a failed update can no longer leave behind a snapshot of a
// version that is still current, which would show up twice in the history
// and be un-revertable.
func (r *wikiPageRepository) UpdateWithRevision(
ctx context.Context, page *types.WikiPage, rev *types.WikiPageRevision,
) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if rev != nil {
// An already-present (page_id, version) pair means a concurrent
// writer snapshotted the same version first; its copy is
// identical, so leaving it alone is correct.
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "page_id"}, {Name: "version"}},
DoNothing: true,
}).Create(rev).Error; err != nil {
return err
}
}
return updateWikiPageRow(tx, page)
})
}
// updateWikiPageRow performs the versioned page write on the given handle
// (plain connection or transaction). On failure page.Version is restored so
// the caller never observes a bumped version for a write that did not land.
func updateWikiPageRow(db *gorm.DB, page *types.WikiPage) error {
expectedVersion := page.Version
page.Version = expectedVersion + 1
result := wikiPageGuardedWrite(db, page).
Model(page).
Where("id = ? AND version = ?", page.ID, expectedVersion).
Updates(map[string]interface{}{
"title": page.Title,
"content": page.Content,
"summary": page.Summary,
"page_type": page.PageType,
"status": page.Status,
"aliases": page.Aliases,
"out_links": page.OutLinks,
"source_refs": page.SourceRefs,
"chunk_refs": page.ChunkRefs,
"page_metadata": page.PageMetadata,
"parent_slug": page.ParentSlug,
"folder_id": page.FolderID,
"category_path": page.CategoryPath,
"wiki_path": page.WikiPath,
"depth": page.Depth,
"sort_order": page.SortOrder,
"last_edit_source": page.LastEditSource,
"last_editor_id": page.LastEditorID,
"version": page.Version,
"updated_at": page.UpdatedAt,
})
if result.Error != nil {
page.Version = expectedVersion
return result.Error
}
if result.RowsAffected == 0 {
page.Version = expectedVersion
// Could be not found or version conflict — check which
var count int64
db.Model(&types.WikiPage{}).Where("id = ?", page.ID).Count(&count)
if count == 0 {
return ErrWikiPageNotFound
}
return ErrWikiPageConflict
}
return nil
}
func wikiPageGuardedWrite(db *gorm.DB, page *types.WikiPage) *gorm.DB {
if guard, ok := types.WikiPageWriteGuardFor(db.Statement.Context, page.ID); ok {
return db.Where("tenant_id = ? AND knowledge_base_id = ? AND slug = ? AND version = ? AND updated_at = ?",
guard.TenantID, guard.KnowledgeBaseID, guard.Slug, guard.Version, guard.UpdatedAt)
}
return db
}
// wikiRevisionListColumns is the projection for revision listings — every
// column except the potentially multi-hundred-KB content body.
const wikiRevisionListColumns = "id, tenant_id, knowledge_base_id, page_id, slug, version, " +
"title, page_type, status, summary, aliases, edit_source, editor_id, edited_at, created_at"
// ListRevisions returns snapshots for a page newest-first, content omitted.
func (r *wikiPageRepository) ListRevisions(
ctx context.Context, kbID string, pageID string, limit int, offset int,
) ([]*types.WikiPageRevision, int64, error) {
base := r.db.WithContext(ctx).Model(&types.WikiPageRevision{}).
Where("knowledge_base_id = ? AND page_id = ?", kbID, pageID)
var total int64
if err := base.Count(&total).Error; err != nil {
return nil, 0, err
}
var revs []*types.WikiPageRevision
if err := base.
Select(wikiRevisionListColumns).
Order("version DESC").
Limit(limit).
Offset(offset).
Find(&revs).Error; err != nil {
return nil, 0, err
}
return revs, total, nil
}
// GetRevision returns one snapshot with content.
func (r *wikiPageRepository) GetRevision(
ctx context.Context, kbID string, pageID string, version int,
) (*types.WikiPageRevision, error) {
var rev types.WikiPageRevision
if err := r.db.WithContext(ctx).
Where("knowledge_base_id = ? AND page_id = ? AND version = ?", kbID, pageID, version).
First(&rev).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrWikiPageNotFound
}
return nil, err
}
return &rev, nil
}
// PruneRevisions applies the two-tier retention described by req: the soft
// cap only touches snapshots whose author is listed as prunable, the hard cap
// applies to everything.
func (r *wikiPageRepository) PruneRevisions(ctx context.Context, req types.WikiRevisionPruneRequest) error {
if req.PageID == "" {
return nil
}
db := r.db.WithContext(ctx)
if req.KeepFromVersion > 0 && len(req.PrunableSources) > 0 {
if err := db.
Where("page_id = ? AND version < ? AND edit_source IN ?",
req.PageID, req.KeepFromVersion, req.PrunableSources).
Delete(&types.WikiPageRevision{}).Error; err != nil {
return err
}
}
if req.HardKeepFromVersion > 0 {
if err := db.
Where("page_id = ? AND version < ?", req.PageID, req.HardKeepFromVersion).
Delete(&types.WikiPageRevision{}).Error; err != nil {
return err
}
}
return nil
}
// DeleteRevisionsByPage hard-deletes a page's whole snapshot history. Pages
// themselves are soft-deleted, but a soft-deleted page is unreachable through
// every read path, so its snapshots would be dead weight — and they are the
// bulkiest rows the wiki stores.
func (r *wikiPageRepository) DeleteRevisionsByPage(ctx context.Context, pageID string) error {
if pageID == "" {
return nil
}
return r.db.WithContext(ctx).
Where("page_id = ?", pageID).
Delete(&types.WikiPageRevision{}).Error
}
// UpdateAutoLinkedContent persists content changes produced by the automatic
// link decorators (cross-link injection, dead-link cleanup) without bumping
// `version`. These passes rewrite the same revision with wiki-link markup
// added or removed; treating them as real edits would make newly-ingested
// pages appear as v2 on first view and confuse users who expect `version` to
// correspond to the number of intentional revisions.
func (r *wikiPageRepository) UpdateAutoLinkedContent(ctx context.Context, page *types.WikiPage) error {
result := wikiPageGuardedWrite(r.db.WithContext(ctx), page).
Model(page).
Where("id = ?", page.ID).
Updates(map[string]interface{}{
"content": page.Content,
"out_links": page.OutLinks,
"updated_at": page.UpdatedAt,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
if _, guarded := types.WikiPageWriteGuardFor(ctx, page.ID); guarded {
return ErrWikiPageConflict
}
return ErrWikiPageNotFound
}
return nil
}
// UpdateMeta updates bookkeeping / provenance fields WITHOUT incrementing the
// version number. "Content" for versioning purposes is the user-visible page
// body (title/content/summary/page_type/status); everything else — links,
// source refs, chunk refs, page_metadata — is considered bookkeeping and is
// refreshed here so the version counter only advances on real edits.
//
// Used by link maintenance, re-ingest (same-content case), and status changes.
func (r *wikiPageRepository) UpdateMeta(ctx context.Context, page *types.WikiPage) error {
result := wikiPageGuardedWrite(r.db.WithContext(ctx), page).
Model(page).
Where("id = ?", page.ID).
Updates(map[string]interface{}{
"in_links": page.InLinks,
"out_links": page.OutLinks,
"aliases": page.Aliases,
"status": page.Status,
"source_refs": page.SourceRefs,
"chunk_refs": page.ChunkRefs,
"page_metadata": page.PageMetadata,
"parent_slug": page.ParentSlug,
"folder_id": page.FolderID,
"category_path": page.CategoryPath,
"wiki_path": page.WikiPath,
"depth": page.Depth,
"sort_order": page.SortOrder,
"updated_at": page.UpdatedAt,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
if _, guarded := types.WikiPageWriteGuardFor(ctx, page.ID); guarded {
return ErrWikiPageConflict
}
return ErrWikiPageNotFound
}
return nil
}
// GetByID retrieves a wiki page by its unique ID
func (r *wikiPageRepository) GetByID(ctx context.Context, id string) (*types.WikiPage, error) {
var page types.WikiPage
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&page).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrWikiPageNotFound
}
return nil, err
}
return &page, nil
}
// GetBySlug retrieves a wiki page by slug within a knowledge base
func (r *wikiPageRepository) GetBySlug(ctx context.Context, kbID string, slug string) (*types.WikiPage, error) {
var page types.WikiPage
if err := r.db.WithContext(ctx).
Where("knowledge_base_id = ? AND slug = ?", kbID, slug).
First(&page).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrWikiPageNotFound
}
return nil, err
}
return &page, nil
}
// List retrieves wiki pages with filtering and pagination
func (r *wikiPageRepository) List(ctx context.Context, req *types.WikiPageListRequest) ([]*types.WikiPage, int64, error) {
query := r.db.WithContext(ctx).Model(&types.WikiPage{}).
Where("knowledge_base_id = ?", req.KnowledgeBaseID)
if pageTypes := types.SplitWikiPageTypes(req.PageType); len(pageTypes) == 1 {
query = query.Where("page_type = ?", pageTypes[0])
} else if len(pageTypes) > 1 {
query = query.Where("page_type IN ?", pageTypes)
}
if req.Status != "" {
query = query.Where("status = ?", req.Status)
}
if req.Query != "" {
// Use PostgreSQL full-text search + ILIKE for aliases
query = query.Where(
"(to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(content, '')) @@ plainto_tsquery('simple', ?) OR aliases::text ILIKE ?)",
req.Query,
"%"+req.Query+"%",
)
}
// Directory filters are pushed to SQL so the DB does the counting and
// pagination instead of loading every page of the type into memory. `depth`
// is a cached column (= len(category_path)); `category_path` is a JSON column
// whose stored text is json.Marshal of the cleaned path, so we compare
// against the same encoding. Postgres needs an explicit jsonb cast for array
// equality; SQLite stores JSON as TEXT and compares directly.
if req.FolderID != nil {
query = query.Where("folder_id = ?", *req.FolderID)
}
if req.CategoryDepth != nil {
query = query.Where("depth = ?", *req.CategoryDepth)
}
if wantPath := types.CleanWikiCategoryPath(req.CategoryPath); len(wantPath) > 0 {
if encoded, err := json.Marshal([]string(wantPath)); err == nil {
if r.db.Dialector != nil && r.db.Dialector.Name() == "postgres" {
query = query.Where("category_path::jsonb = ?::jsonb", string(encoded))
} else {
query = query.Where("category_path = ?", string(encoded))
}
}
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Sort
sortBy := "updated_at"
if req.SortBy != "" {
switch req.SortBy {
case "title", "created_at", "updated_at", "page_type", "wiki_path", "sort_order", "depth":
sortBy = req.SortBy
}
}
sortOrder := "DESC"
if req.SortOrder == "asc" {
sortOrder = "ASC"
}
if sortBy == "wiki_path" {
query = query.Order(r.wikiCategoryRankOrder()).
Order(fmt.Sprintf("wiki_path %s", sortOrder)).
Order("sort_order ASC").
Order("title ASC")
} else {
query = query.Order(fmt.Sprintf("%s %s", sortBy, sortOrder))
}
page := req.Page
if page < 1 {
page = 1
}
pageSize := req.PageSize
if pageSize < 1 {
pageSize = 20
}
offset := (page - 1) * pageSize
// Pagination
query = query.Offset(offset).Limit(pageSize)
var pages []*types.WikiPage
if err := query.Find(&pages).Error; err != nil {
return nil, 0, err
}
return pages, total, nil
}
// ListByType retrieves all wiki pages of a given type within a knowledge base
func (r *wikiPageRepository) ListByType(ctx context.Context, kbID string, pageType string) ([]*types.WikiPage, error) {
var pages []*types.WikiPage
if err := r.db.WithContext(ctx).
Where("knowledge_base_id = ? AND page_type = ?", kbID, pageType).
Order("updated_at DESC").
Find(&pages).Error; err != nil {
return nil, err
}
return pages, nil
}
// ListByTypeLight projects only the columns needed to render an index
// directory entry (slug, title, summary) and paginates by title ASC.
// This keeps the GET /wiki/index response cheap on KBs with tens of
// thousands of pages — the old path loaded every row including its TEXT
// content just to throw the content away on the way out.
//
// Archived pages are excluded. `limit` clamps to [1, 200]; `offset` is
// honored as-is. Returns the total non-archived count for the type
// alongside the page so the caller can render "showing N of M".
func (r *wikiPageRepository) ListByTypeLight(
ctx context.Context,
kbID string,
pageType string,
limit int,
offset int,
) ([]types.WikiIndexEntry, int64, error) {
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
if offset < 0 {
offset = 0
}
base := r.db.WithContext(ctx).
Model(&types.WikiPage{}).
Where("knowledge_base_id = ? AND page_type = ? AND status <> ?",
kbID, pageType, types.WikiPageStatusArchived)
var total int64
if err := base.Count(&total).Error; err != nil {
return nil, 0, err
}
if total == 0 {
return nil, 0, nil
}
var entries []types.WikiIndexEntry
if err := base.
Select("slug", "title", "summary", "parent_slug", "category_path", "wiki_path", "depth", "sort_order").
Order(r.wikiCategoryRankOrder()).
Order("wiki_path ASC").
Order("sort_order ASC").
Order("title ASC").
Limit(limit).
Offset(offset).
Scan(&entries).Error; err != nil {
return nil, 0, err
}
return entries, total, nil
}
// ListBySourceRef retrieves all wiki pages that reference a given source knowledge ID.
// Handles both old format ("knowledgeID") and new format ("knowledgeID|title") in source_refs JSON array.
func (r *wikiPageRepository) ListBySourceRef(ctx context.Context, kbID string, sourceKnowledgeID string) ([]*types.WikiPage, error) {
// Build the JSON needle safely so arbitrary IDs cannot break out of the
// quoted string (e.g. ids containing quotes or backslashes).
needle, err := json.Marshal([]string{sourceKnowledgeID})
if err != nil {
return nil, fmt.Errorf("marshal source ref needle: %w", err)
}
// For the "knowledgeID|title" prefix form, match against the JSON-encoded
// value: json.Marshal escapes special chars so the LIKE pattern is safe.
prefix, err := json.Marshal(sourceKnowledgeID + "|")
if err != nil {
return nil, fmt.Errorf("marshal source ref prefix: %w", err)
}
// prefix is a JSON string including the surrounding quotes; e.g. "abc|".
// We strip the trailing quote so LIKE can continue into the title portion.
prefixStr := string(prefix)
if len(prefixStr) >= 2 && prefixStr[len(prefixStr)-1] == '"' {
prefixStr = prefixStr[:len(prefixStr)-1]
}
// Escape LIKE metacharacters in the already-JSON-escaped prefix, then wrap
// with %…% to match anywhere in the serialized JSON array.
likePattern := "%" + escapeLikePattern(prefixStr) + "%"
var pages []*types.WikiPage
if err := r.db.WithContext(ctx).
Where("knowledge_base_id = ? AND (source_refs @> ?::jsonb OR source_refs::text LIKE ?)",
kbID,
string(needle),
likePattern,
).
Find(&pages).Error; err != nil {
return nil, err
}
return pages, nil
}
// ListSlugsBySourceRef returns just the slugs of pages that reference the
// given knowledge id. Same predicate as ListBySourceRef (both forms
// "knowledgeID" and "knowledgeID|title"), but projected down to a single
// column so the wiki ingest pipeline doesn't have to load full rows when
// it only needs a "before" set of slugs.
//
// Backed by idx_wiki_pages_source_refs (GIN jsonb_path_ops) for the
// containment branch and idx_wiki_pages_source_refs_text for the legacy
// text-LIKE branch — both added in migration 000041.
func (r *wikiPageRepository) ListSlugsBySourceRef(ctx context.Context, kbID string, sourceKnowledgeID string) ([]string, error) {
needle, err := json.Marshal([]string{sourceKnowledgeID})
if err != nil {
return nil, fmt.Errorf("marshal source ref needle: %w", err)
}
prefix, err := json.Marshal(sourceKnowledgeID + "|")
if err != nil {
return nil, fmt.Errorf("marshal source ref prefix: %w", err)
}
prefixStr := string(prefix)
if len(prefixStr) >= 2 && prefixStr[len(prefixStr)-1] == '"' {
prefixStr = prefixStr[:len(prefixStr)-1]
}
likePattern := "%" + escapeLikePattern(prefixStr) + "%"
var slugs []string
if err := r.db.WithContext(ctx).
Model(&types.WikiPage{}).
Where("knowledge_base_id = ? AND (source_refs @> ?::jsonb OR source_refs::text LIKE ?)",
kbID,
string(needle),
likePattern,
).
Pluck("slug", &slugs).Error; err != nil {
return nil, err
}
return slugs, nil
}
// ListBySlugs returns lightweight projections (slug, title, page_type,
// status, aliases, out_links) for the given slugs in one IN query.
// Used by wiki ingest's lazy fetcher path to resolve slug -> title /
// out-links during Map/Reduce without paying for a full ListAll scan.
//
// Empty input returns nil, nil. Slugs not present in the KB are silently
// dropped from the returned map (caller treats absent slugs as "no
// such page" — the same shape ListAll had via missing keys).
func (r *wikiPageRepository) ListBySlugs(
ctx context.Context,
kbID string,
slugs []string,
) (map[string]*types.WikiPageLite, error) {
if len(slugs) == 0 {
return nil, nil
}
var rows []types.WikiPageLite
if err := r.db.WithContext(ctx).
Model(&types.WikiPage{}).
Select("slug", "title", "page_type", "status", "aliases", "out_links").
Where("knowledge_base_id = ? AND slug IN ?", kbID, slugs).
Scan(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]*types.WikiPageLite, len(rows))
for i := range rows {
r := rows[i]
out[r.Slug] = &r
}
return out, nil
}
// ListDistinctCategoryPaths returns the materialized paths of existing wiki
// folders (split into segments), ordered by path and capped at maxPaths. Used
// by the batch taxonomy planner as the candidate pool of folders to reuse
// (similarity preprocessing then narrows it per batch). The folder tree is the
// single source of truth, so this no longer scans page rows.
func (r *wikiPageRepository) ListDistinctCategoryPaths(
ctx context.Context,
kbID string,
maxPaths int,
) ([][]string, error) {
if maxPaths <= 0 {
maxPaths = 150
}
var paths []string
if err := r.db.WithContext(ctx).
Model(&types.WikiFolder{}).
Where("knowledge_base_id = ? AND path <> ?", kbID, "").
Order("path ASC").
Limit(maxPaths).
Pluck("path", &paths).Error; err != nil {
return nil, err
}
out := make([][]string, 0, len(paths))
for _, p := range paths {
if seg := types.CleanWikiCategoryPath(strings.Split(p, "/")); len(seg) > 0 {
out = append(out, seg)
}
}
return out, nil
}
// --- Folder tree (wiki_folders) ---
// ErrWikiFolderNotFound is returned when a wiki folder is not found.
var ErrWikiFolderNotFound = errors.New("wiki folder not found")
// ErrWikiFolderConflict is returned when a sibling folder with the same name
// already exists under the same parent.
var ErrWikiFolderConflict = errors.New("wiki folder name conflict")
// ErrWikiFolderNotEmpty is returned when a folder still has a live page or
// child folder at the instant an atomic delete is attempted.
var ErrWikiFolderNotEmpty = errors.New("wiki folder is not empty")
func (r *wikiPageRepository) CreateFolder(ctx context.Context, folder *types.WikiFolder) error {
return r.db.WithContext(ctx).Create(folder).Error
}
func (r *wikiPageRepository) GetFolderByID(ctx context.Context, kbID string, id string) (*types.WikiFolder, error) {
var folder types.WikiFolder
if err := r.db.WithContext(ctx).
Where("knowledge_base_id = ? AND id = ?", kbID, id).
First(&folder).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrWikiFolderNotFound
}
return nil, err
}
return &folder, nil
}
func (r *wikiPageRepository) GetChildFolderByName(
ctx context.Context, kbID string, parentID string, name string,
) (*types.WikiFolder, error) {
var folder types.WikiFolder
if err := r.db.WithContext(ctx).
Where("knowledge_base_id = ? AND parent_id = ? AND name = ?", kbID, parentID, name).
First(&folder).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrWikiFolderNotFound
}
return nil, err
}
return &folder, nil
}
func (r *wikiPageRepository) ListChildFolders(
ctx context.Context, kbID string, parentID string,
) ([]*types.WikiFolder, error) {
var folders []*types.WikiFolder
if err := r.db.WithContext(ctx).
Where("knowledge_base_id = ? AND parent_id = ?", kbID, parentID).
Order("sort_order ASC").
Order("name ASC").
Find(&folders).Error; err != nil {
return nil, err
}
return folders, nil
}
func (r *wikiPageRepository) ListAllFolders(ctx context.Context, kbID string) ([]*types.WikiFolder, error) {
var folders []*types.WikiFolder
if err := r.db.WithContext(ctx).
Where("knowledge_base_id = ?", kbID).
Order("depth ASC").
Order("path ASC").
Find(&folders).Error; err != nil {
return nil, err
}
return folders, nil
}
func (r *wikiPageRepository) UpdateFolder(ctx context.Context, folder *types.WikiFolder) error {
result := r.db.WithContext(ctx).
Model(&types.WikiFolder{}).
Where("id = ?", folder.ID).
Updates(map[string]interface{}{
"parent_id": folder.ParentID,
"name": folder.Name,
"path": folder.Path,
"depth": folder.Depth,
"sort_order": folder.SortOrder,
"updated_at": folder.UpdatedAt,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrWikiFolderNotFound
}
return nil
}
func (r *wikiPageRepository) DeleteFolder(ctx context.Context, kbID string, id string) error {
// Keep the emptiness test in the same SQL statement as the soft delete.
// A page move or child-folder create can race the service's earlier checks;
// a check-then-delete sequence would otherwise leave a dangling folder_id.
result := r.db.WithContext(ctx).Exec(`
UPDATE wiki_folders
SET deleted_at = ?
WHERE knowledge_base_id = ? AND id = ? AND deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM wiki_pages
WHERE knowledge_base_id = ? AND folder_id = ? AND deleted_at IS NULL
)
AND NOT EXISTS (
SELECT 1 FROM wiki_folders AS child
WHERE child.knowledge_base_id = ? AND child.parent_id = ? AND child.deleted_at IS NULL
)`, time.Now(), kbID, id, kbID, id, kbID, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
var count int64
if err := r.db.WithContext(ctx).Model(&types.WikiFolder{}).
Where("knowledge_base_id = ? AND id = ?", kbID, id).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return ErrWikiFolderNotFound
}
return ErrWikiFolderNotEmpty
}
return nil
}
func (r *wikiPageRepository) CountPagesInFolder(ctx context.Context, kbID string, folderID string) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).
Model(&types.WikiPage{}).
Where("knowledge_base_id = ? AND folder_id = ? AND status <> ?",
kbID, folderID, types.WikiPageStatusArchived).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *wikiPageRepository) CountPagesByFolder(
ctx context.Context, kbID string, pageTypes []string,
) (map[string]int64, error) {
type folderCount struct {
FolderID string
Cnt int64
}
var rows []folderCount
q := r.db.WithContext(ctx).
Model(&types.WikiPage{}).
Select("folder_id, COUNT(*) as cnt").
Where("knowledge_base_id = ? AND status <> ?", kbID, types.WikiPageStatusArchived)
if len(pageTypes) > 0 {
q = q.Where("page_type IN ?", pageTypes)
}
if err := q.Group("folder_id").Scan(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]int64, len(rows))
for _, row := range rows {
out[row.FolderID] = row.Cnt
}
return out, nil
}
func (r *wikiPageRepository) ListPagesByFolderIDs(
ctx context.Context, kbID string, folderIDs []string,
) ([]*types.WikiPage, error) {
if len(folderIDs) == 0 {
return nil, nil
}
var pages []*types.WikiPage
if err := r.db.WithContext(ctx).
Where("knowledge_base_id = ? AND folder_id IN ?", kbID, folderIDs).
Find(&pages).Error; err != nil {
return nil, err
}
return pages, nil
}
// ListSummariesByKnowledgeIDs returns summary-page content keyed by the
// knowledge id that authored it. The page_type filter is applied first
// (only summary pages have content suitable for retract framing); within
// that subset we look at source_refs for either the bare knowledge id
// or the "knowledgeID|title" legacy form.
//
// Empty kids returns nil, nil. A knowledge id with no surviving summary
// page is silently absent from the result map.
//
// Used by reduceSlugUpdates' retract branch so it can frame "what did
// the now-departed sibling document contribute?" for the WikiPageModify
// LLM call without needing to keep the whole batchCtx.SummaryContent
// map in memory ahead of time.
func (r *wikiPageRepository) ListSummariesByKnowledgeIDs(
ctx context.Context,
kbID string,
kids []string,
) (map[string]string, error) {
if len(kids) == 0 {
return nil, nil
}
// Build a JSONB containment-OR with one needle per knowledge id,
// plus a single text-LIKE OR over the legacy prefix forms. The
// containment branches each get their own GIN index probe; the
// LIKE branch falls back to the text fulltext GIN.
type row struct {
Content string `gorm:"column:content"`
SourceRefs types.StringArray `gorm:"column:source_refs"`
}
q := r.db.WithContext(ctx).
Model(&types.WikiPage{}).
Select("content", "source_refs").
Where("knowledge_base_id = ? AND page_type = ? AND status <> ?",
kbID, types.WikiPageTypeSummary, types.WikiPageStatusArchived)
// Build OR clauses without using overly-clever GORM tricks: assemble
// raw SQL fragments + args. Keeping this defensive because source_refs
// patterns include user-controlled knowledge ids.
clauses := make([]string, 0, len(kids)*2)
args := make([]interface{}, 0, len(kids)*2)
for _, kid := range kids {
if kid == "" {
continue
}
needle, err := json.Marshal([]string{kid})
if err != nil {
return nil, fmt.Errorf("marshal kid needle: %w", err)
}
clauses = append(clauses, "source_refs @> ?::jsonb")
args = append(args, string(needle))
prefix, err := json.Marshal(kid + "|")
if err != nil {
return nil, fmt.Errorf("marshal kid prefix: %w", err)
}
prefixStr := string(prefix)
if len(prefixStr) >= 2 && prefixStr[len(prefixStr)-1] == '"' {
prefixStr = prefixStr[:len(prefixStr)-1]
}
clauses = append(clauses, "source_refs::text LIKE ?")
args = append(args, "%"+escapeLikePattern(prefixStr)+"%")
}
if len(clauses) == 0 {
return nil, nil
}
q = q.Where("("+strings.Join(clauses, " OR ")+")", args...)
var rows []row
if err := q.Scan(&rows).Error; err != nil {
return nil, err
}
// Map each row's content to every kid in its source_refs (a single
// summary may carry multiple sources after a previous merge / re-
// ingest). Caller looks up by kid, so duplicates resolve to the
// same content string.
kidSet := make(map[string]struct{}, len(kids))
for _, kid := range kids {
if kid != "" {
kidSet[kid] = struct{}{}
}
}
out := make(map[string]string, len(rows))
for _, r := range rows {
for _, ref := range r.SourceRefs {
refKID := ref
if pipeIdx := strings.Index(ref, "|"); pipeIdx > 0 {
refKID = ref[:pipeIdx]
}
if _, want := kidSet[refKID]; !want {
continue
}
if _, exists := out[refKID]; !exists {
out[refKID] = r.Content
}
}
}
return out, nil
}
// ExistsSlugs reports which of the given slugs are live (non-archived,
// non-deleted) in the KB. Used by cleanDeadLinks to validate out-link
// targets without loading the referenced pages' content. Slugs not
// present in the KB at all map to false; archived slugs also map to
// false so dead-link cleanup treats them as gone.
//
// Empty input returns nil, nil so callers can branch cheaply.
func (r *wikiPageRepository) ExistsSlugs(
ctx context.Context,
kbID string,
slugs []string,
) (map[string]bool, error) {
if len(slugs) == 0 {
return nil, nil
}
var live []string
if err := r.db.WithContext(ctx).
Model(&types.WikiPage{}).
Where("knowledge_base_id = ? AND slug IN ? AND status <> ?",
kbID, slugs, types.WikiPageStatusArchived).
Pluck("slug", &live).Error; err != nil {
return nil, err
}
out := make(map[string]bool, len(slugs))
for _, s := range slugs {
out[s] = false
}
for _, s := range live {
out[s] = true
}
return out, nil
}
// ListAllSlugs returns every non-archived page slug in the KB. Used by
// the lint pipeline to compute the "live slug set" for broken-link
// detection without paying for ListAll's full row materialization.
func (r *wikiPageRepository) ListAllSlugs(
ctx context.Context,
kbID string,
) ([]string, error) {
var slugs []string
if err := r.db.WithContext(ctx).
Model(&types.WikiPage{}).
Where("knowledge_base_id = ? AND status <> ?", kbID, types.WikiPageStatusArchived).
Pluck("slug", &slugs).Error; err != nil {
return nil, err
}
return slugs, nil
}
// ListPagesCursor returns up to `limit` pages for the KB ordered by
// (knowledge_base_id, id) ascending, paginated by an opaque numeric
// cursor. The cursor is the stringified id of the last row from the
// previous page; "" starts from the beginning. Empty nextCursor =
// end-of-stream.
//
// Used by lint to walk the entire KB without ever holding the full
// page set in memory. `limit` is clamped to [1, 500].
func (r *wikiPageRepository) ListPagesCursor(
ctx context.Context,
kbID string,
cursor string,
limit int,
) ([]*types.WikiPage, string, error) {
if limit <= 0 {
limit = 100
}
if limit > 500 {
limit = 500
}
q := r.db.WithContext(ctx).
Where("knowledge_base_id = ? AND status <> ?", kbID, types.WikiPageStatusArchived).
Order("id ASC").
Limit(limit)
if cursor != "" {
q = q.Where("id > ?", cursor)
}
var pages []*types.WikiPage
if err := q.Find(&pages).Error; err != nil {
return nil, "", err
}
nextCursor := ""
if len(pages) == limit {
nextCursor = pages[len(pages)-1].ID
}
return pages, nextCursor, nil
}
// ListByTypeRecent returns up to `limit` summary-typed pages ordered
// by updated_at DESC, projected to slug/title/summary. Used by the
// rebuildIndexPage first-time generation path — historically that
// loaded EVERY summary page and concatenated them into the prompt,
// which broke the LLM context window once a KB grew past a few
// thousand documents. The recent-N projection caps the prompt size
// at the cost of intro framing for very old documents (which are
// unlikely to be the most-relevant index introductions anyway).
//
// `limit` is clamped to [1, 1000]; 0 falls back to 200.
func (r *wikiPageRepository) ListByTypeRecent(
ctx context.Context,
kbID string,
pageType string,
limit int,
) ([]types.WikiIndexEntry, error) {
if limit <= 0 {
limit = 200
}
if limit > 1000 {
limit = 1000
}
var entries []types.WikiIndexEntry
if err := r.db.WithContext(ctx).
Model(&types.WikiPage{}).
Select("slug", "title", "summary").
Where("knowledge_base_id = ? AND page_type = ? AND status <> ?",
kbID, pageType, types.WikiPageStatusArchived).
Order("updated_at DESC").
Limit(limit).
Scan(&entries).Error; err != nil {
return nil, err
}
return entries, nil
}
// FindSimilarPages returns the top-k entity/concept pages whose lowercase
// title is most similar to the given query under PostgreSQL pg_trgm
// trigram similarity. Backed by idx_wiki_pages_title_trgm (GIN
// gin_trgm_ops, migration 000041). Used by the dedup pre-filter to
// surface candidate merge targets without loading every entity/concept
// page into Go.
//
// types is an optional page_type allow-list; empty means entity+concept.
// limit is clamped to [1, 50]. Pages whose title similarity is below
// 0.1 are dropped server-side via the `%` operator (which respects
// pg_trgm.similarity_threshold).
func (r *wikiPageRepository) FindSimilarPages(
ctx context.Context,
kbID string,
query string,
pageTypes []string,
limit int,
) ([]*types.WikiPageLite, error) {
if strings.TrimSpace(query) == "" {
return nil, nil
}
if limit <= 0 {
limit = 20
}
if limit > 50 {
limit = 50
}
if len(pageTypes) == 0 {
pageTypes = []string{types.WikiPageTypeEntity, types.WikiPageTypeConcept}
}
q := strings.ToLower(strings.TrimSpace(query))
var rows []types.WikiPageLite
if err := r.db.WithContext(ctx).
Model(&types.WikiPage{}).
Select("slug, title, page_type, status, aliases, out_links, similarity(lower(title), ?) AS sim", q).
Where("knowledge_base_id = ? AND page_type IN ? AND status <> ? AND lower(title) % ?",
kbID, pageTypes, types.WikiPageStatusArchived, q).
Order("sim DESC").
Limit(limit).
Scan(&rows).Error; err != nil {
return nil, err
}
out := make([]*types.WikiPageLite, len(rows))
for i := range rows {
r := rows[i]
out[i] = &r
}
return out, nil
}
func wikiNormalizedTitleSQL(db *gorm.DB) string {
if db != nil && db.Dialector != nil && db.Dialector.Name() == "sqlite" {
// SQLite has no POSIX [[:space:]]. Strip the common separators that
// model formatting drift actually produces; callers still re-check
// with the Go identity fold so extras cannot leak through.
return "lower(replace(replace(replace(replace(replace(replace(title, ' ', ''), char(9), ''), char(10), ''), char(13), ''), char(160), ''), char(12288), ''))"
}
return "regexp_replace(lower(title), '[[:space:]]+', '', 'g')"
}
const (
wikiNormalizedTitleQueryChunk = 100
wikiNormalizedTitleRowCap = 500
)
func wikiNormalizedTitleLookupLimit(n int) int {
if n <= 0 {
return 0
}
limit := n * 8
if limit < 50 {
limit = 50
}
if limit > wikiNormalizedTitleRowCap {
limit = wikiNormalizedTitleRowCap
}
return limit
}
func uniqNormalizedTitleIdentities(identities []string) []string {
if len(identities) == 0 {
return nil
}
seen := make(map[string]struct{}, len(identities))
out := make([]string, 0, len(identities))
for _, identity := range identities {
identity = strings.TrimSpace(identity)
if identity == "" {
continue
}
if _, ok := seen[identity]; ok {
continue
}
seen[identity] = struct{}{}
out = append(out, identity)
}
return out
}
// FindPagesByNormalizedTitle returns non-archived pages of pageType whose
// whitespace-stripped, lowercased title equals identity.
func (r *wikiPageRepository) FindPagesByNormalizedTitle(
ctx context.Context,
kbID, pageType, identity string,
) ([]*types.WikiPageLite, error) {
return r.FindPagesByNormalizedTitles(ctx, kbID, pageType, []string{identity})
}
// FindPagesByNormalizedTitles returns non-archived pages of pageType whose
// whitespace-stripped, lowercased title is in identities.
func (r *wikiPageRepository) FindPagesByNormalizedTitles(
ctx context.Context,
kbID, pageType string,
identities []string,
) ([]*types.WikiPageLite, error) {
identities = uniqNormalizedTitleIdentities(identities)
if kbID == "" || pageType == "" || len(identities) == 0 {
return nil, nil
}
normSQL := wikiNormalizedTitleSQL(r.db)
out := make([]*types.WikiPageLite, 0, len(identities))
for start := 0; start < len(identities); start += wikiNormalizedTitleQueryChunk {
end := start + wikiNormalizedTitleQueryChunk
if end > len(identities) {
end = len(identities)
}
chunk := identities[start:end]
var rows []types.WikiPageLite
if err := r.db.WithContext(ctx).
Model(&types.WikiPage{}).
Select("slug, title, page_type, status, aliases, out_links").
Where("knowledge_base_id = ? AND page_type = ? AND status <> ? AND "+normSQL+" IN ?",
kbID, pageType, types.WikiPageStatusArchived, chunk).
Order("slug ASC").
Limit(wikiNormalizedTitleLookupLimit(len(chunk))).
Scan(&rows).Error; err != nil {
return nil, err
}
for i := range rows {
row := rows[i]
out = append(out, &row)
}
}
return out, nil
}
// ListAll retrieves all non-archived wiki pages in a knowledge base.
func (r *wikiPageRepository) ListAll(ctx context.Context, kbID string) ([]*types.WikiPage, error) {
var pages []*types.WikiPage
if err := r.db.WithContext(ctx).
Where("knowledge_base_id = ? AND status <> ?", kbID, types.WikiPageStatusArchived).
Order("page_type ASC, title ASC").
Find(&pages).Error; err != nil {
return nil, err
}
return pages, nil
}
// ListRecentForSuggestions returns recent user-visible wiki pages across the given
// knowledge bases, used as a fallback source for agent suggested questions when
// the KB has no FAQ entries or AI-generated document questions (typical for
// Wiki-only KBs). Excludes the index page and archived pages.
func (r *wikiPageRepository) ListRecentForSuggestions(
ctx context.Context,
tenantID uint64,
kbIDs []string,
limit int,
) ([]*types.WikiPage, error) {
if len(kbIDs) == 0 || limit <= 0 {
return nil, nil
}
var pages []*types.WikiPage
if err := r.db.WithContext(ctx).
Where("tenant_id = ?", tenantID).
Where("knowledge_base_id IN ?", kbIDs).
Where("page_type <> ?", types.WikiPageTypeIndex).
Where("status = ?", types.WikiPageStatusPublished).
Where("title <> ''").
Order("updated_at DESC").
Limit(limit).
Find(&pages).Error; err != nil {
return nil, err
}
return pages, nil
}
// Delete soft-deletes a wiki page by knowledge base ID and slug
func (r *wikiPageRepository) Delete(ctx context.Context, kbID string, slug string) error {
result := r.db.WithContext(ctx).
Where("knowledge_base_id = ? AND slug = ?", kbID, slug).
Delete(&types.WikiPage{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrWikiPageNotFound
}
return nil
}
// DeleteByID soft-deletes a wiki page by ID
func (r *wikiPageRepository) DeleteByID(ctx context.Context, id string) error {
result := r.db.WithContext(ctx).
Where("id = ?", id).
Delete(&types.WikiPage{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrWikiPageNotFound
}
return nil
}
// escapeLikePattern escapes LIKE / ILIKE metacharacters so the returned string
// can be safely concatenated with % wildcards without unintended matches.
// Order matters: escape the backslash first, then the wildcards.
func escapeLikePattern(s string) string {
replacer := strings.NewReplacer(
`\`, `\\`,
`%`, `\%`,
`_`, `\_`,
)
return replacer.Replace(s)
}
// Search performs case-insensitive POSIX regex search on wiki pages within a knowledge base.
// The query is interpreted as a PostgreSQL regular expression (via ~*).
//
// Results are ranked by where the query hit, highest-relevance first:
//
// title hit → rank 4 (most obvious intent: user typed what the page is called)
// slug hit → rank 3 (url-like identifiers, direct jump)
// summary hit → rank 2 (short authored abstract)
// content hit → rank 1 (body mention — often surfaces unrelated pages whose
// prose merely mentions the query as trivia)
//
// Without this ranking, a user searching for "王新" on a 4万-page wiki will
// see pages like "华为" or "Index" ahead of the actual 王新 page just
// because they mention 王新 in their body and were updated more recently.
// updated_at stays as the tiebreaker so same-rank ties stay deterministic.
func (r *wikiPageRepository) Search(ctx context.Context, kbID string, query string, limit int) ([]*types.WikiPage, error) {
if limit <= 0 {
limit = 10
}
if limit > 50 {
limit = 50
}
// CASE expression is evaluated per-row during SELECT; we order by the
// alias so the DB only computes the rank once. Parameterized four
// times with the same regex to avoid coupling to GORM's positional
// arg rewriting quirks.
rankExpr := "CASE " +
"WHEN title ~* ? THEN 4 " +
"WHEN slug ~* ? THEN 3 " +
"WHEN summary ~* ? THEN 2 " +
"WHEN content ~* ? THEN 1 " +
"ELSE 0 END AS match_rank"
var pages []*types.WikiPage
if err := r.db.WithContext(ctx).
Select("*, "+rankExpr, query, query, query, query).
Where("knowledge_base_id = ? AND (title ~* ? OR content ~* ? OR summary ~* ? OR slug ~* ?)",
kbID, query, query, query, query).
Where("status != ?", "archived").
Order("match_rank DESC, updated_at DESC").
Limit(limit).
Find(&pages).Error; err != nil {
return nil, err
}
return pages, nil
}
// CountByType returns page counts grouped by type for a knowledge base
func (r *wikiPageRepository) CountByType(ctx context.Context, kbID string) (map[string]int64, error) {
type result struct {
PageType string
Count int64
}
var results []result
if err := r.db.WithContext(ctx).
Model(&types.WikiPage{}).
Select("page_type, count(*) as count").
Where("knowledge_base_id = ? AND status <> ?", kbID, types.WikiPageStatusArchived).
Group("page_type").
Scan(&results).Error; err != nil {
return nil, err
}
counts := make(map[string]int64)
for _, r := range results {
counts[r.PageType] = r.Count
}
return counts, nil
}
// CountOrphans returns the number of pages with no inbound links
func (r *wikiPageRepository) CountOrphans(ctx context.Context, kbID string) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).
Model(&types.WikiPage{}).
Where("knowledge_base_id = ? AND status <> ?", kbID, types.WikiPageStatusArchived).
Where(r.wikiEmptyInLinksPredicate()).
// Exclude the index page because it is naturally a root page.
Where("page_type <> ?", types.WikiPageTypeIndex).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *wikiPageRepository) CreateIssue(ctx context.Context, issue *types.WikiPageIssue) error {
return r.db.WithContext(ctx).Create(issue).Error
}
func (r *wikiPageRepository) ListIssues(ctx context.Context, kbID string, slug string, status string) ([]*types.WikiPageIssue, error) {
query := r.db.WithContext(ctx).Where("knowledge_base_id = ?", kbID)
if slug != "" {
query = query.Where("slug = ?", slug)
}
if status != "" {
query = query.Where("status = ?", status)
}
var issues []*types.WikiPageIssue
if err := query.Order("created_at DESC").Find(&issues).Error; err != nil {
return nil, err
}
return issues, nil
}
func (r *wikiPageRepository) UpdateIssueStatus(ctx context.Context, issueID string, status string) error {
return r.db.WithContext(ctx).Model(&types.WikiPageIssue{}).
Where("id = ?", issueID).
Update("status", status).Error
}