diff --git a/internal/application/service/knowledge.go b/internal/application/service/knowledge.go index 3222f045..c46ce72e 100644 --- a/internal/application/service/knowledge.go +++ b/internal/application/service/knowledge.go @@ -1239,10 +1239,12 @@ func (s *knowledgeService) DeleteKnowledge(ctx context.Context, id string) error return nil }) - // Clean up wiki pages that reference this knowledge + // Clean up wiki pages that reference this knowledge. Pass the full + // knowledge object so cleanup can source title/summary from the row + // itself rather than reaching into possibly-not-yet-written wiki pages. if kb != nil && kb.IsWikiEnabled() { wg.Go(func() error { - s.cleanupWikiOnKnowledgeDelete(ctx, knowledge.KnowledgeBaseID, knowledge.ID) + s.cleanupWikiOnKnowledgeDelete(ctx, knowledge) return nil }) } @@ -1255,39 +1257,69 @@ func (s *knowledgeService) DeleteKnowledge(ctx context.Context, id string) error } // cleanupWikiOnKnowledgeDelete handles wiki pages when a source document is deleted. -// - Pages with only this source ref → archived (kept for audit, hidden from normal views) -// - Pages with multiple source refs → source ref removed, page stays published -// - Index and log pages are rebuilt afterward -func (s *knowledgeService) cleanupWikiOnKnowledgeDelete(ctx context.Context, kbID, knowledgeID string) { - // Find all wiki pages referencing this knowledge ID +// +// There are three sources of truth we must keep consistent: +// - The knowledge row (being soft-deleted right now by the caller) +// - Wiki pages whose source_refs include this knowledge +// - Pending/in-flight wiki_ingest tasks that may create *new* pages pointing at it +// +// The function is deliberately best-effort and idempotent: +// - It writes a tombstone + scrubs pending ingest ops so new pages cannot be +// born with a stale source_ref (guards (a) queued ingest and (b) ingest +// tasks mid-LLM call — both consult the tombstone before writing). +// - It immediately reconciles any pages already present (delete-if-only-ref +// or strip-ref-if-multi). +// - It *unconditionally* enqueues a retract task. Crucially we DO NOT gate +// enqueue on "pages currently exist": in the ingest/delete race the +// knowledge may have pages that exist only after this function returns +// (the ingest task fires later and, absent the tombstone, would have +// created them). The retract handler re-queries ListPagesBySourceRef at +// run time, so even with an empty PageSlugs it will do the right thing — +// and at worst it's a cheap no-op. +func (s *knowledgeService) cleanupWikiOnKnowledgeDelete(ctx context.Context, knowledge *types.Knowledge) { + if knowledge == nil { + return + } + kbID := knowledge.KnowledgeBaseID + knowledgeID := knowledge.ID + if kbID == "" || knowledgeID == "" { + return + } + + // (1) Tombstone + scrub pending ingest — must happen first so any + // wiki_ingest task that wakes up between here and the retract enqueue + // below sees "knowledge gone" and bails out. + s.markKnowledgeDeletedForWiki(ctx, kbID, knowledgeID) + s.scrubWikiPendingIngest(ctx, kbID, knowledgeID) + + // Pull title/summary from the knowledge itself — do NOT read them from + // existing wiki pages. In the race window wiki pages may not exist yet, + // and even when they do their "summary" is the LLM-extracted one which + // we're about to invalidate anyway. The knowledge row still has the + // original Title/FileName/Description, which is what the retract prompt + // actually wants. + docTitle := knowledge.Title + if docTitle == "" { + docTitle = knowledge.FileName + } + if docTitle == "" { + docTitle = knowledgeID + } + docSummary := knowledge.Description + + // (2) Immediate reconciliation for pages already present. If ingest + // hasn't run yet this simply finds nothing; that's fine — see (3). pages, err := s.wikiRepo.ListBySourceRef(ctx, kbID, knowledgeID) if err != nil { logger.Warnf(ctx, "wiki cleanup: failed to list pages by source ref %s: %v", knowledgeID, err) - return - } - if len(pages) == 0 { - return + pages = nil } - // Extract doc title from source refs for retract prompt - docTitle := knowledgeID + // Prefer the on-disk summary if the summary page already exists (it's + // richer than the raw user-provided description). Leave docSummary + // untouched otherwise so we still pass something meaningful downstream. for _, page := range pages { - for _, ref := range page.SourceRefs { - prefix := knowledgeID + "|" - if strings.HasPrefix(ref, prefix) { - docTitle = ref[len(prefix):] - break - } - } - if docTitle != knowledgeID { - break - } - } - - // Capture the summary page's description before deleting (for retract changeDesc) - var docSummary string - for _, page := range pages { - if page.PageType == types.WikiPageTypeSummary { + if page.PageType == types.WikiPageTypeSummary && page.Summary != "" { docSummary = page.Summary break } @@ -1296,23 +1328,19 @@ func (s *knowledgeService) cleanupWikiOnKnowledgeDelete(ctx context.Context, kbI var deletedSlugs []string var retractSlugs []string for _, page := range pages { - // Skip index/log system pages if page.PageType == types.WikiPageTypeIndex || page.PageType == types.WikiPageTypeLog { continue } - // Check if this is the only source ref (match by prefix "knowledgeID|" or exact "knowledgeID") remaining := removeSourceRef(page.SourceRefs, knowledgeID) if len(remaining) == 0 { - // No other sources → delete the page entirely if err := s.wikiService.DeletePage(ctx, kbID, page.Slug); err != nil { logger.Warnf(ctx, "wiki cleanup: failed to delete page %s: %v", page.Slug, err) } else { deletedSlugs = append(deletedSlugs, page.Slug) } } else { - // Other sources remain → remove source ref and queue LLM content retraction page.SourceRefs = remaining if err := s.wikiService.UpdatePageMeta(ctx, page); err != nil { logger.Warnf(ctx, "wiki cleanup: failed to update source refs for page %s: %v", page.Slug, err) @@ -1329,20 +1357,87 @@ func (s *knowledgeService) cleanupWikiOnKnowledgeDelete(ctx context.Context, kbI allAffectedSlugs := append(retractSlugs, deletedSlugs...) - // Enqueue async LLM retraction and index rebuild - if len(allAffectedSlugs) > 0 { - lang, _ := types.LanguageFromContext(ctx) - tenantID, _ := types.TenantIDFromContext(ctx) - EnqueueWikiRetract(ctx, s.task, s.redisClient, WikiRetractPayload{ - TenantID: tenantID, - KnowledgeBaseID: kbID, - KnowledgeID: knowledgeID, - DocTitle: docTitle, - DocSummary: docSummary, - Language: lang, - PageSlugs: allAffectedSlugs, - }) - logger.Infof(ctx, "wiki cleanup: enqueued retract task for %d pages: %v", len(allAffectedSlugs), allAffectedSlugs) + // (3) Unconditionally enqueue the retract task. See function comment — + // an empty PageSlugs is not a bug, it's the signal "re-query at run + // time". The handler will ListPagesBySourceRef again, pick up any + // pages that materialised after we looked, and also rebuild index/log + // so the knowledge's disappearance is reflected in the UI. + lang, _ := types.LanguageFromContext(ctx) + tenantID, _ := types.TenantIDFromContext(ctx) + EnqueueWikiRetract(ctx, s.task, s.redisClient, WikiRetractPayload{ + TenantID: tenantID, + KnowledgeBaseID: kbID, + KnowledgeID: knowledgeID, + DocTitle: docTitle, + DocSummary: docSummary, + Language: lang, + PageSlugs: allAffectedSlugs, + }) + logger.Infof(ctx, "wiki cleanup: enqueued retract task for knowledge %s (%d known slugs: %v)", + knowledgeID, len(allAffectedSlugs), allAffectedSlugs) +} + +// markKnowledgeDeletedForWiki writes a short-TTL tombstone so any wiki_ingest +// task still running or queued for this knowledge can short-circuit before +// resurrecting a page with a stale source_ref. No-op when Redis is absent. +func (s *knowledgeService) markKnowledgeDeletedForWiki(ctx context.Context, kbID, knowledgeID string) { + if s.redisClient == nil || kbID == "" || knowledgeID == "" { + return + } + key := WikiDeletedTombstoneKey(kbID, knowledgeID) + if err := s.redisClient.Set(ctx, key, "1", wikiDeletedTTL).Err(); err != nil { + logger.Warnf(ctx, "wiki cleanup: failed to write tombstone %s: %v", key, err) + } +} + +// scrubWikiPendingIngest removes queued WikiOpIngest entries for a knowledge +// from the debounced pending list, so the next batch doesn't re-ingest a +// document we're about to soft-delete. Retract entries stay put — the wiki +// still needs them to unlink referencing pages. +// +// We use LREM against JSON-encoded entries plus a best-effort raw-UUID +// fallback for backward compatibility with the legacy format documented in +// peekPendingList. +func (s *knowledgeService) scrubWikiPendingIngest(ctx context.Context, kbID, knowledgeID string) { + if s.redisClient == nil || kbID == "" || knowledgeID == "" { + return + } + pendingKey := wikiPendingKeyPrefix + kbID + + // Best-effort: inspect the list, remove matching ingest entries one by one. + // The list is bounded (wikiMaxDocsPerBatch at a time on the consumer + // side, practical uploads rarely exceed a few dozen), so a single LRange + // is safe. + items, err := s.redisClient.LRange(ctx, pendingKey, 0, -1).Result() + if err != nil { + logger.Warnf(ctx, "wiki cleanup: failed to read pending list %s: %v", pendingKey, err) + return + } + removed := 0 + for _, item := range items { + // Legacy raw-UUID form + if item == knowledgeID { + if n, err := s.redisClient.LRem(ctx, pendingKey, 0, item).Result(); err == nil { + removed += int(n) + } + continue + } + if !strings.HasPrefix(item, "{") { + continue + } + var op WikiPendingOp + if err := json.Unmarshal([]byte(item), &op); err != nil { + continue + } + if op.KnowledgeID != knowledgeID || op.Op != WikiOpIngest { + continue + } + if n, err := s.redisClient.LRem(ctx, pendingKey, 0, item).Result(); err == nil { + removed += int(n) + } + } + if removed > 0 { + logger.Infof(ctx, "wiki cleanup: scrubbed %d pending ingest ops for knowledge %s", removed, knowledgeID) } } @@ -1503,12 +1598,16 @@ func (s *knowledgeService) DeleteKnowledgeList(ctx context.Context, ids []string return nil }) - // Clean up wiki pages that reference deleted knowledge + // Clean up wiki pages that reference deleted knowledge. cleanup needs + // the full knowledge object (Title / Description) so the retract prompt + // can describe the vanished document even when wiki pages haven't been + // ingested yet — which is common in the batch-delete-shortly-after-upload + // flow. wg.Go(func() error { for _, knowledge := range knowledgeList { kb, _ := s.kbService.GetKnowledgeBaseByID(ctx, knowledge.KnowledgeBaseID) if kb != nil && kb.IsWikiEnabled() { - s.cleanupWikiOnKnowledgeDelete(ctx, knowledge.KnowledgeBaseID, knowledge.ID) + s.cleanupWikiOnKnowledgeDelete(ctx, knowledge) } } return nil diff --git a/internal/application/service/wiki_ingest.go b/internal/application/service/wiki_ingest.go index 1f14f7d0..801aac76 100644 --- a/internal/application/service/wiki_ingest.go +++ b/internal/application/service/wiki_ingest.go @@ -42,8 +42,28 @@ const ( // Prevents unbounded execution time. Remaining docs stay in the pending list // and are picked up by the follow-up task. wikiMaxDocsPerBatch = 5 + + // wikiDeletedKeyPrefix is the Redis key prefix for "recently deleted + // knowledge" tombstones. Key: wiki:deleted:{kbID}:{knowledgeID}. Written + // by cleanupWikiOnKnowledgeDelete so that any wiki_ingest task still in + // flight (or queued) for this knowledge can fast-path skip without + // hitting the DB. TTL > wikiIngestDelay so it's guaranteed to outlast + // any in-flight ingest. + wikiDeletedKeyPrefix = "wiki:deleted:" + + // wikiDeletedTTL bounds how long we remember a deletion. Must comfortably + // exceed the longest plausible ingest run (LLM extraction + reduce). + wikiDeletedTTL = 1 * time.Hour ) +// WikiDeletedTombstoneKey returns the Redis key used to mark a knowledge as +// recently deleted, so wiki_ingest tasks in flight can short-circuit. Exposed +// so knowledgeService.cleanupWikiOnKnowledgeDelete can write the same key +// without duplicating the format string. +func WikiDeletedTombstoneKey(kbID, knowledgeID string) string { + return wikiDeletedKeyPrefix + kbID + ":" + knowledgeID +} + // WikiIngestPayload is the asynq task payload for wiki ingest batch trigger. // The actual document IDs are stored in a Redis list (wiki:pending:{kbID}). // KnowledgeID is only used as fallback in Lite mode (no Redis). @@ -865,6 +885,68 @@ func (s *wikiIngestService) generateWithTemplate(ctx context.Context, chatModel // --- Helpers --- +// isKnowledgeGone returns true if the given knowledge has been deleted or is +// in the middle of being deleted. It first consults the Redis tombstone +// (written by cleanupWikiOnKnowledgeDelete) as a fast path, then falls back +// to the DB. A nil result from GetKnowledgeByIDOnly also counts as gone: the +// repo layer uses GORM First() which filters soft-deleted rows, so a +// soft-deleted knowledge surfaces as "not found" here — exactly what we want. +func (s *wikiIngestService) isKnowledgeGone(ctx context.Context, kbID, knowledgeID string) bool { + if knowledgeID == "" { + return true + } + if s.redisClient != nil { + if exists, err := s.redisClient.Exists(ctx, WikiDeletedTombstoneKey(kbID, knowledgeID)).Result(); err == nil && exists > 0 { + return true + } + } + kn, err := s.knowledgeSvc.GetKnowledgeByIDOnly(ctx, knowledgeID) + if err != nil || kn == nil { + return true + } + return kn.ParseStatus == types.ParseStatusDeleting +} + +// filterLiveUpdates drops additions/summaries whose source knowledge has been +// deleted since the Map phase finished. Retract updates are preserved so +// pages still get cleaned up. Caches per-knowledge results to avoid DB +// hammering when a single reduce slug carries many updates for the same doc. +func (s *wikiIngestService) filterLiveUpdates(ctx context.Context, kbID string, updates []SlugUpdate) []SlugUpdate { + if len(updates) == 0 { + return updates + } + goneCache := make(map[string]bool) + isGone := func(kid string) bool { + if kid == "" { + return false + } + if v, ok := goneCache[kid]; ok { + return v + } + v := s.isKnowledgeGone(ctx, kbID, kid) + goneCache[kid] = v + return v + } + filtered := make([]SlugUpdate, 0, len(updates)) + dropped := 0 + for _, u := range updates { + switch u.Type { + case "retract", "retractStale": + filtered = append(filtered, u) + default: + if isGone(u.KnowledgeID) { + dropped++ + continue + } + filtered = append(filtered, u) + } + } + if dropped > 0 { + logger.Infof(ctx, "wiki ingest: reduce dropped %d updates for deleted knowledge(s)", dropped) + } + return filtered +} + // reconstructContent rebuilds document text from chunks. // // This only concatenates text-type chunks — image OCR / caption information is diff --git a/internal/application/service/wiki_ingest_batch.go b/internal/application/service/wiki_ingest_batch.go index ee4a98a6..df914524 100644 --- a/internal/application/service/wiki_ingest_batch.go +++ b/internal/application/service/wiki_ingest_batch.go @@ -222,13 +222,54 @@ func (s *wikiIngestService) ProcessWikiIngest(ctx context.Context, t *asynq.Task op := op eg.Go(func() error { if op.Op == WikiOpRetract { + // Resolve the authoritative page set at run-time. The caller + // (knowledgeService.cleanupWikiOnKnowledgeDelete) captures + // PageSlugs from a DB snapshot taken *before* this task fires, + // but there is a window where: + // - cleanup ran before ingest → snapshot is empty, but a + // concurrent ingest may have already created pages by now + // - a previous ingest batch created new pages after cleanup + // captured its snapshot + // Re-querying ListPagesBySourceRef here unions the caller's + // slugs with whatever currently references the knowledge, so + // no page is left un-retracted. It also lets us support + // callers that deliberately enqueue retract with empty + // PageSlugs as "figure it out yourself" — see + // cleanupWikiOnKnowledgeDelete's comment (3). + slugSet := make(map[string]struct{}, len(op.PageSlugs)) + for _, slug := range op.PageSlugs { + if slug == "" { + continue + } + slugSet[slug] = struct{}{} + } + if op.KnowledgeID != "" { + livePages, err := s.wikiService.ListPagesBySourceRef(mapCtx, payload.KnowledgeBaseID, op.KnowledgeID) + if err != nil { + logger.Warnf(mapCtx, "wiki ingest: retract lookup failed for %s: %v", op.KnowledgeID, err) + } else { + for _, p := range livePages { + if p == nil || p.Slug == "" { + continue + } + // Index/log pages never carry real source_refs; + // if they somehow surface here, skip — the + // reduce stage would be a no-op anyway. + if p.PageType == types.WikiPageTypeIndex || p.PageType == types.WikiPageTypeLog { + continue + } + slugSet[p.Slug] = struct{}{} + } + } + } + mapMu.Lock() retractOps++ retractHandled++ - docPreview = append(docPreview, fmt.Sprintf("retract[%s]: %s", previewText(op.KnowledgeID, 24), previewText(op.DocTitle, 48))) + docPreview = append(docPreview, fmt.Sprintf("retract[%s]: %s (%d slugs)", previewText(op.KnowledgeID, 24), previewText(op.DocTitle, 48), len(slugSet))) fmt.Fprintf(&retractChangeDesc, "\n%s\n%s\n\n\n", op.DocTitle, op.DocSummary) - for _, slug := range op.PageSlugs { + for slug := range slugSet { slugUpdates[slug] = append(slugUpdates[slug], SlugUpdate{ Slug: slug, Type: "retract", @@ -372,6 +413,16 @@ func (s *wikiIngestService) mapOneDocument( knowledgeID := op.KnowledgeID lang := op.Language + // Guard against the ingest/delete race: if the user deleted the doc while + // this task was queued (wikiIngestDelay = 30s) or while an earlier stage + // was in flight, we must NOT proceed to LLM extraction — doing so would + // create wiki pages whose source_refs point at a ghost knowledge ID, + // permanently unreachable via wiki_read_source_doc. + if s.isKnowledgeGone(ctx, payload.KnowledgeBaseID, knowledgeID) { + logger.Infof(ctx, "wiki ingest: knowledge %s has been deleted, skip map", knowledgeID) + return nil, nil, nil + } + chunks, err := s.chunkRepo.ListChunksByKnowledgeID(ctx, payload.TenantID, knowledgeID) if err != nil { return nil, nil, fmt.Errorf("get chunks: %w", err) @@ -603,6 +654,17 @@ func (s *wikiIngestService) reduceSlugUpdates( tenantID uint64, batchCtx *WikiBatchContext, ) (bool, string, error) { + // Final safety net for the ingest/delete race: between Map (which already + // checks isKnowledgeGone) and Reduce there is a long LLM call where the + // source document may be deleted. Drop any addition/summary updates whose + // knowledge no longer exists so we don't resurrect a ghost source_ref. + // Retract updates are kept — they actively remove refs, which is what we + // want when the doc is gone. + updates = s.filterLiveUpdates(ctx, kbID, updates) + if len(updates) == 0 { + return false, "", nil + } + page, err := s.wikiService.GetPageBySlug(ctx, kbID, slug) exists := (err == nil && page != nil) diff --git a/internal/application/service/wiki_lint.go b/internal/application/service/wiki_lint.go index 0f438749..273f309b 100644 --- a/internal/application/service/wiki_lint.go +++ b/internal/application/service/wiki_lint.go @@ -55,18 +55,21 @@ type WikiLintReport struct { // WikiLintService provides wiki health checking capabilities type WikiLintService struct { - wikiService interfaces.WikiPageService - kbService interfaces.KnowledgeBaseService + wikiService interfaces.WikiPageService + kbService interfaces.KnowledgeBaseService + knowledgeService interfaces.KnowledgeService } // NewWikiLintService creates a new wiki lint service func NewWikiLintService( wikiService interfaces.WikiPageService, kbService interfaces.KnowledgeBaseService, + knowledgeService interfaces.KnowledgeService, ) *WikiLintService { return &WikiLintService{ - wikiService: wikiService, - kbService: kbService, + wikiService: wikiService, + kbService: kbService, + knowledgeService: knowledgeService, } } @@ -157,7 +160,51 @@ func (s *WikiLintService) RunLint(ctx context.Context, kbID string) (*WikiLintRe } } - // Check 4: Missing cross-references (entities mentioned in content but not linked) + // Check 4: Stale source refs — source_refs pointing at soft-deleted + // knowledge. This is the primary self-heal for the ingest/delete race + // condition: if a wiki_ingest task managed to slip past the in-flight + // guards and wrote a page for a knowledge that has since been deleted, + // the page becomes a dead-end (wiki_read_source_doc returns "knowledge + // not found"). Flag it so AutoFix can strip the ref, and delete the + // page if no live refs remain. + if s.knowledgeService != nil { + knowledgeLive := make(map[string]bool) // kid -> exists + for _, page := range resp.Pages { + // Skip wiki-intrinsic pages: index/log never carry SourceRefs + // anyway, and accidentally flagging them would risk AutoFix + // deleting system pages. + if page.PageType == types.WikiPageTypeIndex || page.PageType == types.WikiPageTypeLog { + continue + } + for _, ref := range page.SourceRefs { + kid := ref + if i := strings.Index(ref, "|"); i > 0 { + kid = ref[:i] + } + if kid == "" { + continue + } + live, seen := knowledgeLive[kid] + if !seen { + kn, err := s.knowledgeService.GetKnowledgeByIDOnly(ctx, kid) + live = err == nil && kn != nil + knowledgeLive[kid] = live + } + if !live { + issues = append(issues, WikiLintIssue{ + Type: LintIssueStaleRef, + Severity: SeverityError, + PageSlug: page.Slug, + TargetSlug: kid, + Description: fmt.Sprintf("Page '%s' references deleted knowledge %s", page.Title, kid), + AutoFixable: true, + }) + } + } + } + } + + // Check 5: Missing cross-references (entities mentioned in content but not linked) entitySlugs := make(map[string]string) // slug -> title for _, page := range resp.Pages { if page.PageType == types.WikiPageTypeEntity || page.PageType == types.WikiPageTypeConcept { @@ -310,6 +357,34 @@ func (s *WikiLintService) AutoFix(ctx context.Context, kbID string) (int, error) if _, err := s.wikiService.UpdatePage(ctx, page); err == nil { fixed++ } + + case LintIssueStaleRef: + // Strip source_refs that point at soft-deleted knowledge. If the + // page has no other live sources, delete it outright — leaving + // an orphan summary page is worse than removing it, because the + // model would still link to it from other pages and the + // wiki_read_source_doc drill-down would always fail. + if issue.TargetSlug == "" { + continue + } + page, err := s.wikiService.GetPageBySlug(ctx, kbID, issue.PageSlug) + if err != nil || page == nil { + continue + } + if page.PageType == types.WikiPageTypeIndex || page.PageType == types.WikiPageTypeLog { + continue + } + remaining := removeSourceRef(page.SourceRefs, issue.TargetSlug) + if len(remaining) == 0 { + if err := s.wikiService.DeletePage(ctx, kbID, page.Slug); err == nil { + fixed++ + } + } else if len(remaining) != len(page.SourceRefs) { + page.SourceRefs = remaining + if err := s.wikiService.UpdatePageMeta(ctx, page); err == nil { + fixed++ + } + } } } diff --git a/internal/application/service/wiki_page.go b/internal/application/service/wiki_page.go index 58a63019..ef3b25ef 100644 --- a/internal/application/service/wiki_page.go +++ b/internal/application/service/wiki_page.go @@ -355,6 +355,13 @@ func (s *wikiPageService) ListAllPages(ctx context.Context, kbID string) ([]*typ return s.repo.ListAll(ctx, kbID) } +// ListPagesBySourceRef exposes the repository's source-ref lookup so higher +// layers (delete flow, retract reconciliation) can re-query the current wiki +// state without depending on a stale caller-captured slug list. +func (s *wikiPageService) ListPagesBySourceRef(ctx context.Context, kbID string, knowledgeID string) ([]*types.WikiPage, error) { + return s.repo.ListBySourceRef(ctx, kbID, knowledgeID) +} + // SearchPages performs full-text search over wiki pages func (s *wikiPageService) SearchPages(ctx context.Context, kbID string, query string, limit int) ([]*types.WikiPage, error) { return s.repo.Search(ctx, kbID, query, limit) diff --git a/internal/types/interfaces/wiki_page.go b/internal/types/interfaces/wiki_page.go index 7d7dc7cf..63949670 100644 --- a/internal/types/interfaces/wiki_page.go +++ b/internal/types/interfaces/wiki_page.go @@ -63,6 +63,12 @@ type WikiPageService interface { // Used for index rebuild, graph generation, cross-link injection, etc. ListAllPages(ctx context.Context, kbID string) ([]*types.WikiPage, error) + // ListPagesBySourceRef retrieves all wiki pages whose source_refs reference + // the given knowledge ID. Used by delete/ingest reconciliation paths that + // need to find pages touched by a specific document at read time (rather + // than relying on a caller-provided stale snapshot). + ListPagesBySourceRef(ctx context.Context, kbID string, knowledgeID string) ([]*types.WikiPage, error) + // SearchPages performs full-text search over wiki pages. SearchPages(ctx context.Context, kbID string, query string, limit int) ([]*types.WikiPage, error)