From fdccb68ee4c79b33b7436f493adb2b4bbba2a6d9 Mon Sep 17 00:00:00 2001 From: wizardchen Date: Tue, 21 Apr 2026 14:20:52 +0800 Subject: [PATCH] feat: Improve wiki cleanup and ingest handling for deleted knowledge - Enhanced the `cleanupWikiOnKnowledgeDelete` function to pass the full knowledge object, allowing for more accurate title and summary retrieval during wiki cleanup. - Introduced a tombstone mechanism in Redis to mark recently deleted knowledge, enabling in-flight wiki ingest tasks to skip processing for deleted documents. - Updated the `isKnowledgeGone` function to check for deleted knowledge using the tombstone, improving efficiency in handling concurrent delete and ingest operations. - Added logic in the `wikiIngestService` to filter out updates for deleted knowledge, ensuring that no ghost references are created during the ingest process. - Implemented lint checks for stale source references in the `WikiLintService`, allowing for automatic cleanup of pages referencing deleted knowledge. These changes significantly enhance the robustness of the wiki system, ensuring consistency and accuracy in handling knowledge deletions and related wiki pages. --- internal/application/service/knowledge.go | 197 +++++++++++++----- internal/application/service/wiki_ingest.go | 82 ++++++++ .../application/service/wiki_ingest_batch.go | 66 +++++- internal/application/service/wiki_lint.go | 85 +++++++- internal/application/service/wiki_page.go | 7 + internal/types/interfaces/wiki_page.go | 6 + 6 files changed, 387 insertions(+), 56 deletions(-) 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)