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
1360 lines
44 KiB
Go
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
|
|
}
|