package service import ( "context" "encoding/json" "errors" "fmt" "sort" "strings" "sync" "time" "unicode/utf8" "github.com/Tencent/WeKnora/internal/agent" apprepo "github.com/Tencent/WeKnora/internal/application/repository" "github.com/Tencent/WeKnora/internal/logger" "github.com/Tencent/WeKnora/internal/models/chat" "github.com/Tencent/WeKnora/internal/models/requestbudget" "github.com/Tencent/WeKnora/internal/tracing/langfuse" "github.com/Tencent/WeKnora/internal/types" "github.com/google/uuid" "github.com/hibiken/asynq" "golang.org/x/sync/errgroup" ) // scheduleFollowUp enqueues another asynq trigger task if there are // still pending ops in task_pending_ops for this KB. Returns true when // a follow-up was scheduled. // // Post-Phase-3 this only backstops the case where a batch drained its // claimed window but more rows remain and no other trigger is pending // (e.g. steady trickle of uploads). Standard mode already fans a KB's // backlog across concurrent claiming batches, so the short delay is // normally just a light debounce rather than a lock-release wait. // // `delay` is the ProcessIn before the follow-up fires. Callers pass // wikiFollowUpDelay for the normal case and wikiRateLimitBackoff when the // batch tripped an upstream rate limit — the released failed rows are // eligible immediately, but nothing claims them until a trigger fires, so // stretching the follow-up interval is what actually paces retries down // during a 429 storm. func (s *wikiIngestService) scheduleFollowUp(ctx context.Context, payload WikiIngestPayload, delay time.Duration) bool { if s.pendingRepo == nil { return false } count, err := s.pendingRepo.PendingCount(ctx, wikiTaskType, wikiTaskScope, payload.KnowledgeBaseID) if err != nil || count == 0 { return false } logger.Infof(ctx, "wiki ingest: %d more documents pending for KB %s, scheduling follow-up in %s", count, payload.KnowledgeBaseID, delay) langfuse.InjectTracing(ctx, &payload) payloadBytes, _ := json.Marshal(payload) t := asynq.NewTask(types.TypeWikiIngest, payloadBytes, asynq.Queue(types.QueueWiki), asynq.MaxRetry(wikiIngestMaxRetry), asynq.Timeout(60*time.Minute), asynq.ProcessIn(delay), // debounce (or rate-limit backoff) before draining the remainder ) if _, err := s.task.Enqueue(t); err != nil { logger.Warnf(ctx, "wiki ingest: follow-up enqueue failed: %v", err) return false } return true } // newWikiBatchContext builds the per-run lazy fetchers used by both the ingest // batch and the debounced finalize task. These replace the legacy pre-batch // ListAllPages dump: instead of pulling ~100MB of rows up front (and walking // them several more times), callers pay only for the slugs / knowledge ids they // actually reach for. Cache hits keep repeat lookups within a single run free. // The cache is per-call (goroutine-safe via fetchMu), so each task gets a fresh, // isolated view. func (s *wikiIngestService) newWikiBatchContext( kbID string, wikiConfig *types.WikiConfig, ) *WikiBatchContext { var ( fetchMu sync.Mutex slugTitleCache = make(map[string]string) // slug -> title; "" = known-missing summaryKIDCache = make(map[string]string) // kid -> content; "" = known-missing ) resolveSlugs := func(ctx context.Context, slugs []string) map[string]string { // Filter to the slugs we don't already have cached. fetchMu.Lock() need := slugs[:0:0] for _, slug := range slugs { if _, ok := slugTitleCache[slug]; ok { continue } need = append(need, slug) } fetchMu.Unlock() if len(need) > 0 { pages, err := s.wikiService.ListBySlugs(ctx, kbID, need) if err != nil { logger.Warnf(ctx, "wiki ingest: ListBySlugs(%d slugs) failed: %v", len(need), err) } fetchMu.Lock() for _, slug := range need { if p, ok := pages[slug]; ok && p != nil { if p.Status == types.WikiPageStatusArchived || p.PageType == types.WikiPageTypeIndex { // Treat archived / system pages as missing from the // title-resolution map: cleanDeadLinks shouldn't link // to them or surface them as cross-link candidates. slugTitleCache[slug] = "" continue } slugTitleCache[slug] = p.Title } else { slugTitleCache[slug] = "" } } fetchMu.Unlock() } out := make(map[string]string, len(slugs)) fetchMu.Lock() for _, slug := range slugs { if title := slugTitleCache[slug]; title != "" { out[slug] = title } } fetchMu.Unlock() return out } resolveSummaries := func(ctx context.Context, kids []string) map[string]string { fetchMu.Lock() need := kids[:0:0] for _, kid := range kids { if _, ok := summaryKIDCache[kid]; ok { continue } need = append(need, kid) } fetchMu.Unlock() if len(need) > 0 { contents, err := s.wikiService.ListSummariesByKnowledgeIDs(ctx, kbID, need) if err != nil { logger.Warnf(ctx, "wiki ingest: ListSummariesByKnowledgeIDs(%d kids) failed: %v", len(need), err) } fetchMu.Lock() for _, kid := range need { if c, ok := contents[kid]; ok && c != "" { summaryKIDCache[kid] = c } else { summaryKIDCache[kid] = "" } } fetchMu.Unlock() } out := make(map[string]string, len(kids)) fetchMu.Lock() for _, kid := range kids { if content := summaryKIDCache[kid]; content != "" { out[kid] = content } } fetchMu.Unlock() return out } granularity := types.WikiExtractionStandard contentInstructions := "" extractionInstructions := "" if wikiConfig != nil { granularity = wikiConfig.ExtractionGranularity.Normalize() contentInstructions = wikiConfig.ContentInstructions extractionInstructions = wikiConfig.ExtractionInstructions } return &WikiBatchContext{ SlugTitle: func(ctx context.Context, slug string) string { m := resolveSlugs(ctx, []string{slug}) return m[slug] }, SlugTitleMany: resolveSlugs, SummaryContentByKnowledgeID: func(ctx context.Context, kid string) string { m := resolveSummaries(ctx, []string{kid}) return m[kid] }, ExtractionGranularity: granularity, ContentInstructions: contentInstructions, ExtractionInstructions: extractionInstructions, } } func (s *wikiIngestService) ProcessWikiIngest(ctx context.Context, t *asynq.Task) (retErr error) { taskStartedAt := time.Now() retryCount, _ := asynq.GetRetryCount(ctx) maxRetry, _ := asynq.GetMaxRetry(ctx) var payload WikiIngestPayload exitStatus := "success" mode := "redis" pendingOpsCount := 0 ingestOps := 0 retractOps := 0 ingestSucceeded := 0 ingestFailed := 0 retractHandled := 0 followUpScheduled := false totalPagesAffected := 0 docPreview := make([]string, 0, 6) // Tunables resolved from KB.WikiConfig once we've loaded the KB. // Captured up here so the deferred stats log can observe them // regardless of which exit path we took. (Index-intro rebuild moved to // the debounced wiki:finalize task in Phase 1, so it's logged there, not // here; the exclusive per-KB lock was removed in Phase 3.) loggedBatchSize := 0 loggedMapPar := 0 loggedReducePar := 0 loggedMaxInflight := 0 defer func() { logger.Infof( ctx, "wiki ingest stats: kb=%s tenant=%d retry=%d/%d status=%s elapsed=%s mode=%s pending_ops=%d ops(ingest=%d,retract=%d) ingest(success=%d,failed=%d) retract_handled=%d pages(total=%d) followup=%v tunables(batch=%d,map_par=%d,reduce_par=%d,max_inflight=%d) preview=%s", payload.KnowledgeBaseID, payload.TenantID, retryCount, maxRetry, exitStatus, time.Since(taskStartedAt).Round(time.Millisecond), mode, pendingOpsCount, ingestOps, retractOps, ingestSucceeded, ingestFailed, retractHandled, totalPagesAffected, followUpScheduled, loggedBatchSize, loggedMapPar, loggedReducePar, loggedMaxInflight, previewStringSlice(docPreview, 6), ) }() if err := json.Unmarshal(t.Payload(), &payload); err != nil { exitStatus = "invalid_payload" return fmt.Errorf("wiki ingest: unmarshal payload: %w", err) } // Inject context ctx = context.WithValue(ctx, types.TenantIDContextKey, payload.TenantID) if payload.Language != "" { ctx = context.WithValue(ctx, types.LanguageContextKey, payload.Language) } if err := s.checkWikiRecoveryBudgets(ctx, payload); err != nil { return err } // Concurrency model (Phase 3): // // - Standard (Redis) mode: NO exclusive per-KB lock. Multiple batches // for the same KB may run at once, each claiming a DISJOINT set of // rows via claimPendingList (below). This lets one KB's backlog fan // out across the whole wiki worker pool instead of draining one // 5-doc batch at a time. Same-slug reduce safety is provided by // withSlugLock, not by a batch-wide lock. // // - Lite mode (no Redis, single process): keep the in-process // liteLocks guard so exactly one batch per KB runs at a time. Lite // targets small/local scale where serial-per-KB is simplest and the // claiming machinery (which needs FOR UPDATE SKIP LOCKED) buys // nothing. if s.redisClient == nil { mode = "lite" if _, loaded := s.liteLocks.LoadOrStore(payload.KnowledgeBaseID, struct{}{}); loaded { exitStatus = "active_lock_conflict" logger.Infof(ctx, "wiki ingest: another batch active for KB %s (lite lock), deferring to asynq retry", payload.KnowledgeBaseID) return ErrWikiIngestConcurrent } defer s.liteLocks.Delete(payload.KnowledgeBaseID) } kb, err := s.kbService.GetKnowledgeBaseByIDOnly(ctx, payload.KnowledgeBaseID) if errors.Is(err, apprepo.ErrKnowledgeBaseNotFound) || (err == nil && kb == nil) { exitStatus = "kb_deleted" if cleanupErr := s.clearDeletedKnowledgeBasePendingOps(ctx, payload.KnowledgeBaseID); cleanupErr != nil { return fmt.Errorf("wiki ingest: clear deleted KB queue: %w", cleanupErr) } return nil } if err != nil { exitStatus = "get_kb_failed" return fmt.Errorf("wiki ingest: get KB: %w", err) } if !kb.IsWikiEnabled() { exitStatus = "kb_not_wiki_enabled" return fmt.Errorf("wiki ingest: KB %s is not wiki type", kb.ID) } var synthesisModelID string if kb.WikiConfig != nil { synthesisModelID = kb.WikiConfig.SynthesisModelID } if synthesisModelID == "" { synthesisModelID = kb.SummaryModelID } if synthesisModelID == "" { exitStatus = "missing_synthesis_model" return fmt.Errorf("wiki ingest: no synthesis model configured for KB %s", kb.ID) } chatModel, err := s.modelService.GetChatModel(ctx, synthesisModelID) if err != nil { exitStatus = "get_chat_model_failed" return fmt.Errorf("wiki ingest: get chat model: %w", err) } if err := s.checkWikiProvider(ctx, wikiProviderCircuitKey(payload.TenantID, chatModel.GetModelID())); err != nil { exitStatus = "provider_cooldown" followUpScheduled = s.scheduleFollowUp(ctx, payload, wikiRateLimitBackoff) if !followUpScheduled { return err } return nil } // Resolve per-KB tunables once. WikiConfig.IngestBatchSize / // IngestMapParallel / IngestReduceParallel let operators on // 4w-document KBs raise the throughput knob (more docs per batch + // more concurrent LLM calls) without a code deploy. Zero falls back // to the historical defaults so existing KBs see no behaviour // change until they opt in. batchSize := kb.WikiConfig.IngestBatchSizeOrDefault(wikiMaxDocsPerBatch) if s.checkpointDB != nil { // A durable map/page continuation belongs to one pending document; // claim one row so page group membership cannot change on resumption. batchSize = 1 } mapParallel := kb.WikiConfig.IngestMapParallelOrDefault(10) reduceParallel := kb.WikiConfig.IngestReduceParallelOrDefault(10) loggedBatchSize = batchSize loggedMapPar = mapParallel loggedReducePar = reduceParallel lang := types.LanguageNameFromContext(ctx) // Per-KB in-flight cap (Phase 4, standard mode): keep one KB's bulk // import from monopolizing the shared wiki pool. If the KB is already at // its cap we schedule a coalesced retry and bail without claiming, so the // rows stay available for whichever running batch frees a slot first. maxInflight := kb.WikiConfig.IngestMaxInflightOrDefault(wikiInflightDefault) loggedMaxInflight = maxInflight releaseSlot, gotSlot := s.reserveInflightSlot(ctx, payload.KnowledgeBaseID, maxInflight) if !gotSlot { exitStatus = "inflight_cap" logger.Infof(ctx, "wiki ingest: KB %s at in-flight cap (%d), rescheduling", payload.KnowledgeBaseID, maxInflight) s.scheduleCappedRetry(ctx, payload) return nil } defer releaseSlot() // Standard mode claims rows (marks claimed_at, disjoint across concurrent // batches); Lite mode peeks under its in-process lock. var pendingOps []WikiPendingOp var peekedIDs []int64 var loadErr error if s.redisClient != nil { pendingOps, peekedIDs, loadErr = s.claimPendingList(ctx, payload.KnowledgeBaseID, batchSize) } else { pendingOps, peekedIDs, loadErr = s.peekPendingList(ctx, payload.KnowledgeBaseID, batchSize) } if loadErr != nil { // Transient failure loading the pending list. Return an error so // asynq retries this trigger with backoff — acking here would strand // the queue until an unrelated upload happened to re-trigger it. exitStatus = "load_pending_failed" logger.Warnf(ctx, "wiki ingest: failed to load pending list for KB %s: %v", payload.KnowledgeBaseID, loadErr) return fmt.Errorf("wiki ingest: load pending list: %w", loadErr) } pendingOpsCount = len(pendingOps) if len(pendingOps) == 0 { exitStatus = "no_pending_ops" logger.Infof(ctx, "wiki ingest: no pending operations for KB %s", payload.KnowledgeBaseID) // We claimed nothing, but rows may still exist held by a FRESH claim // (a concurrent batch that is still running, or one that crashed // mid-flight and left claimed_at stamped). This no-op return does not // chain a follow-up, so without a safety net a crashed batch's rows // would sit unclaimable until wikiClaimStaleAfter AND never get // re-triggered afterwards — stranding the KB indefinitely. Schedule a // coalesced recheck timed past the stale threshold so those rows are // reclaimed automatically once eligible. followUpScheduled = s.scheduleStaleClaimRecheck(ctx, payload) return nil } logger.Infof(ctx, "wiki ingest: batch processing %d ops for KB %s", len(pendingOps), payload.KnowledgeBaseID) // Crash/abort safety net (standard/claim mode only). If this batch exits // abnormally — panic, ctx timeout, or an early error return — BEFORE it // settles its claimed rows (trim + requeueFailedOps), release the claims // so the next trigger can re-claim within seconds instead of waiting out // wikiClaimStaleAfter (~90m). On the normal path claimsSettled flips true // once the rows reach their terminal state, making this a no-op. Uses a // bounded detached cleanup context because ctx may already be cancelled on // the timeout path. Lite mode peeks without claiming, so there is nothing // to release. claimsSettled := false if s.redisClient != nil && len(peekedIDs) > 0 { defer func() { if claimsSettled { return } releaseCtx, releaseCancel := wikiIngestCleanupContext(ctx) defer releaseCancel() if err := s.pendingRepo.ReleaseByIDs(releaseCtx, peekedIDs); err != nil { logger.Warnf(ctx, "wiki ingest: failed to release %d claims on abnormal exit for KB %s: %v", len(peekedIDs), payload.KnowledgeBaseID, err) return } logger.Warnf(ctx, "wiki ingest: released %d claimed rows on abnormal exit for KB %s (re-claimable immediately)", len(peekedIDs), payload.KnowledgeBaseID) }() } var recovery types.WikiRecoveryIdentity for _, op := range pendingOps { if op.WikiRecoveryIdentity.Present() { recovery = op.WikiRecoveryIdentity break } } if recovery.Present() { defer func() { retErr = s.settleWikiRecoveryStop(ctx, recovery, payload.TenantID, retErr) }() if len(pendingOps) != 1 || !recovery.Valid() || pendingOps[0].dbID != recovery.PendingOpID || pendingOps[0].recoveryConflict { return fmt.Errorf("%w: mixed or invalid ingest recovery scope", ErrWikiRecoveryBudgetStopped) } var recoveryCancel context.CancelFunc ctx, recoveryCancel, err = s.bindWikiRecovery(ctx, payload, recovery, pendingOps[0].KnowledgeID, synthesisModelID) defer recoveryCancel() if err != nil { return err } var conflicts int64 if err := s.checkpointDB.WithContext(ctx).Model(&types.TaskPendingOp{}). Where("tenant_id = ? AND scope_id = ? AND task_type = ? AND dedup_key = ? AND id <> ?", payload.TenantID, payload.KnowledgeBaseID, wikiTaskType, pendingOps[0].KnowledgeID, recovery.PendingOpID).Count(&conflicts).Error; err != nil { return err } if conflicts != 0 { return fmt.Errorf("%w: concurrent document operation", ErrWikiRecoveryBudgetStopped) } } else if err := s.rejectOrdinaryRecoveryOverlap(ctx, payload, pendingOps); err != nil { return err } // Resolve extraction granularity once per batch. Historical rows with // empty/unknown values fall back to Standard via Normalize(). Failures // to load the KB (unlikely since we're already acting on it) also // degrade gracefully to Standard. var wikiConfig *types.WikiConfig if kb, kbErr := s.kbService.GetKnowledgeBaseByID(ctx, payload.KnowledgeBaseID); kbErr == nil && kb != nil && kb.WikiConfig != nil { wikiConfig = kb.WikiConfig } batchCtx := s.newWikiBatchContext(payload.KnowledgeBaseID, wikiConfig) if s.checkpointDB != nil { var sliceCancel context.CancelFunc ctx, sliceCancel = context.WithTimeoutCause(ctx, wikiCheckpointSliceTimeout, errWikiCheckpointYield) defer sliceCancel() } // 1. MAP PHASE (Parallel extraction and generation of updates) var mapMu sync.Mutex var failedOps []WikiPendingOp var yieldedOps []WikiPendingOp var recoveryErr error slugUpdates := make(map[string][]SlugUpdate) var docResults []*docIngestResult var retractFolderIDs []string // rateLimited flips true when any map/reduce LLM failure looks like an // upstream 429/quota trip. It steers the follow-up scheduler onto the // longer wikiRateLimitBackoff so retries don't keep hammering an already // saturated rpm budget. Guarded by mapMu (map phase) / reduceMu (reduce // phase) — both are held when written. rateLimited := false eg, mapCtx := errgroup.WithContext(ctx) eg.SetLimit(mapParallel) // Map phase limit (configurable via WikiConfig) for _, op := range pendingOps { 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)) folderSet := make(map[string]struct{}, len(op.FolderIDs)) for _, slug := range op.PageSlugs { if slug == "" { continue } slugSet[slug] = struct{}{} } for _, folderID := range op.FolderIDs { if folderID != "" { folderSet[folderID] = 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 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 { continue } slugSet[p.Slug] = struct{}{} if p.FolderID != "" { folderSet[p.FolderID] = struct{}{} } } } } mapMu.Lock() retractOps++ retractHandled++ docPreview = append(docPreview, fmt.Sprintf("retract[%s]: %s (%d slugs)", previewText(op.KnowledgeID, 24), previewText(op.DocTitle, 48), len(slugSet))) for slug := range slugSet { slugUpdates[slug] = append(slugUpdates[slug], SlugUpdate{ Slug: slug, Type: "retract", RetractDocContent: op.DocSummary, DocTitle: op.DocTitle, KnowledgeID: op.KnowledgeID, Language: types.ResolveLanguageName(ctx, op.Language), }) } for folderID := range folderSet { retractFolderIDs = append(retractFolderIDs, folderID) } mapMu.Unlock() return nil } // Ingest mapMu.Lock() ingestOps++ mapMu.Unlock() logger.Infof(mapCtx, "wiki ingest: processing document '%s' (%s)", op.DocTitle, op.KnowledgeID) result, updates, err := s.mapOneDocument(mapCtx, chatModel, payload, op, batchCtx) err = wikiCheckpointError(mapCtx, err) if err != nil { mapMu.Lock() if errors.Is(err, ErrWikiRecoveryBudgetStopped) { recoveryErr = err mapMu.Unlock() return nil } if errors.Is(err, errWikiCheckpointYield) { yieldedOps = append(yieldedOps, op) mapMu.Unlock() return nil } ingestFailed++ failedOps = append(failedOps, op) if recovery.Present() && recoveryErr == nil { recoveryErr = err } if isLikelyRateLimitError(err) { rateLimited = true } mapMu.Unlock() logger.Warnf(mapCtx, "wiki ingest: failed to map knowledge %s: %v", op.KnowledgeID, err) return nil // Don't fail the whole batch } if result != nil { result.WikiRecoveryIdentity = recovery mapMu.Lock() ingestSucceeded++ docResults = append(docResults, result) docPreview = append(docPreview, fmt.Sprintf("ingest[%s]: title=%s summary=%s", previewText(result.KnowledgeID, 24), previewText(result.DocTitle, 40), previewText(result.Summary, 64))) for _, u := range updates { u.WikiRecoveryIdentity = recovery slugUpdates[u.Slug] = append(slugUpdates[u.Slug], u) } mapMu.Unlock() // No fail-count reset needed: a successful op is added // to peekedIDs and gets DELETEd from task_pending_ops at // trim time, so there is no stale fail_count column to // scrub. Compare with the legacy Redis path, which kept // a separate wiki:failcount:<...> key alive for 24h // regardless of whether the original op had drained. // // The finalizing slot is drained later (after reduce + // publish) in the docResults loop, so "completed" only // arrives once wiki is fully written. } else { if recovery.Present() { mapMu.Lock() if recoveryErr == nil { recoveryErr = errors.New("source cannot produce a complete map") } mapMu.Unlock() return nil } // err == nil && result == nil: mapOneDocument skipped this // doc at a terminal, non-retryable state (knowledge // deleted / no chunks / insufficient text). It produces no // docResult and is not a failedOp, so neither the success // nor the dead-letter drain path will fire. Release the // finalizing slot here so the row doesn't hang in // "finalizing" until the housekeeping sweep marks it // failed. The matching +1 was seeded by // KnowledgePostProcess.SetFinalizing. s.finalizeWikiSubtask(mapCtx, op.KnowledgeID) } return nil }) } _ = eg.Wait() if recoveryErr != nil { return recoveryErr } if err := wikiCheckpointError(ctx, requestbudget.Check(ctx)); recovery.Present() && err != nil && !errors.Is(err, errWikiCheckpointYield) { return err } if recovery.Present() && len(failedOps) > 0 { return errors.New("document mapping failed") } // Re-read identity claims after every map worker has chosen a slug so // concurrent romanizations of the same title collapse before taxonomy // planning and Reduce lock by slug. var identityErr error slugUpdates, identityErr = s.remapWikiSourceIdentityUpdates(ctx, payload.KnowledgeBaseID, slugUpdates, batchCtx) if identityErr != nil { return identityErr } if s.checkpointDB != nil { for _, result := range docResults { result.Pages = nil for slug, updates := range slugUpdates { for _, update := range updates { if update.KnowledgeID != result.KnowledgeID || update.Type == "retract" || update.Type == "retractStale" { continue } title := update.Item.Name if update.Type == types.WikiPageTypeSummary { title = update.DocTitle } result.Pages = append(result.Pages, wikiIngestPageRef{Slug: slug, Title: title}) break } } } } // Plan the directory once for the whole batch BEFORE reduce. Reduce writes // pages in parallel, so it can't converge on shared folders on its own; this // single pass assigns every new entity/concept slug a coherent category_path // that reuses existing folders. Reduce then only applies the plan to pages // that don't already have a category (user-curated pages are never churned). planned, taxonomyErr := s.planBatchTaxonomyChecked(ctx, chatModel, kb, slugUpdates, lang) if taxonomyErr != nil && recovery.Present() { return taxonomyErr } batchCtx.PlannedFolderID = s.resolvePlannedFolders(ctx, kb, planned) // 2. REDUCE PHASE (Parallel upserting grouped by Slug) egReduce, reduceCtx := errgroup.WithContext(ctx) egReduce.SetLimit(reduceParallel) // Reduce phase limit (LLM + DB concurrent connections, configurable) var reduceMu sync.Mutex var allPagesAffected []string var ingestPagesAffected []string var retractPagesAffected []string // failedAdditionSlugs collects entity/concept slugs whose page // generation LLM call failed (so the page was never written). The // post-reduce cleanup step uses this set to strip dead [[slug]] // references from the same batch's summary pages and exclude failed // pages from finalize processing. failedAdditionSlugs := make(map[string]struct{}) // unappliedSlugKIDs collects the knowledge_ids that contributed to a // slug whose update never landed — either because we could NOT acquire // the per-slug lock within wikiSlugLockWait, or because reduce returned // an error. In both cases the page keeps its prior content, so the // owning document(s) must be re-queued rather than trimmed — otherwise // the row is deleted and the contribution is silently lost forever // (finalize only rebuilds the index / cross-links, it does not re-run // reduce). requeueFailedOps' fail_count budget bounds the retries and // dead-letters a document whose slug fails/stays hot permanently. unappliedSlugKIDs := make(map[string]struct{}) yieldedSlugKIDs := make(map[string]struct{}) unwrittenSlugs := make(map[string]struct{}) // collectUnapplied records every knowledge_id backing a slug we failed // to apply. Caller holds no lock; it takes reduceMu itself. collectUnapplied := func(updates []SlugUpdate) { reduceMu.Lock() for _, u := range updates { if u.KnowledgeID != "" { unappliedSlugKIDs[u.KnowledgeID] = struct{}{} } } reduceMu.Unlock() } // Build the kid → wikiSpan lookup before kicking off reduce. Each // per-slug reduce attaches a postprocess.wiki.page[slug] subspan // under the FIRST contributing doc's wiki span — see comment in // reduceSlugUpdates for the multi-contributor attribution rule. kidToWikiSpan := make(map[string]*Span, len(docResults)) for _, r := range docResults { if r != nil && r.WikiSpan != nil { kidToWikiSpan[r.KnowledgeID] = r.WikiSpan } } for slug, updates := range slugUpdates { slug := slug updates := updates egReduce.Go(func() error { for _, update := range updates { if update.WikiRecoveryIdentity != recovery { reduceMu.Lock() recoveryErr = fmt.Errorf("%w: mixed reduce identities", ErrWikiRecoveryBudgetStopped) reduceMu.Unlock() return nil } } var ( changed bool affectedType string additionFailed bool reduceErr error ) // Serialize same-slug read-modify-write across concurrent batches // (standard mode). runs fn directly in Lite mode. acquired, lockErr := s.withSlugLock(reduceCtx, payload.KnowledgeBaseID, slug, func(slugCtx context.Context) error { changed, affectedType, additionFailed, reduceErr = s.reduceSlugUpdates( slugCtx, chatModel, payload.KnowledgeBaseID, slug, updates, payload.TenantID, batchCtx, kidToWikiSpan) // Reduction outcome is handled below; only the lease implementation // may report lockErr, otherwise failures bypass addition/rate-limit tracking. return nil }) if lockErr != nil { lockErr = wikiCheckpointError(reduceCtx, lockErr) if errors.Is(lockErr, errWikiCheckpointYield) { reduceMu.Lock() unwrittenSlugs[slug] = struct{}{} for _, update := range updates { yieldedSlugKIDs[update.KnowledgeID] = struct{}{} } reduceMu.Unlock() return nil } if recovery.Present() { reduceMu.Lock() if recoveryErr == nil || errors.Is(lockErr, ErrWikiRecoveryBudgetStopped) { recoveryErr = lockErr } reduceMu.Unlock() } collectUnapplied(updates) // ctx cancelled (batch timeout / shutdown) — stop quietly. return nil } if !acquired { // Contended slug we couldn't get in time. The page keeps its // prior content, so the documents that fed this slug are NOT // done: record their knowledge_ids so the trim phase re-queues // them (via the failed-op retry budget) for a later, // hopefully-uncontended batch instead of deleting their rows. logger.Warnf(reduceCtx, "wiki ingest: slug %s busy > %s, deferring update", slug, wikiSlugLockWait) collectUnapplied(updates) return nil } if reduceErr != nil { reduceErr = wikiCheckpointError(reduceCtx, reduceErr) if errors.Is(reduceErr, ErrWikiRecoveryBudgetStopped) { reduceMu.Lock() recoveryErr = reduceErr reduceMu.Unlock() return nil } if errors.Is(reduceErr, errWikiCheckpointYield) { reduceMu.Lock() unwrittenSlugs[slug] = struct{}{} for _, update := range updates { yieldedSlugKIDs[update.KnowledgeID] = struct{}{} } reduceMu.Unlock() return nil } // The page's read-modify-write failed, so this slug's update // never landed. Re-queue the contributing documents (same // fail_count budget as a map failure) so a later batch retries // instead of silently dropping the row at trim time. if recovery.Present() { reduceMu.Lock() if recoveryErr == nil { recoveryErr = reduceErr } reduceMu.Unlock() } logger.Warnf(reduceCtx, "wiki ingest: reduce failed for slug %s: %v", slug, reduceErr) collectUnapplied(updates) if isLikelyRateLimitError(reduceErr) { reduceMu.Lock() rateLimited = true reduceMu.Unlock() } } if changed { reduceMu.Lock() allPagesAffected = append(allPagesAffected, slug) if affectedType == "ingest" { ingestPagesAffected = append(ingestPagesAffected, slug) } else if affectedType == "retract" { retractPagesAffected = append(retractPagesAffected, slug) } reduceMu.Unlock() } if additionFailed { collectUnapplied(updates) reduceMu.Lock() failedAdditionSlugs[slug] = struct{}{} reduceMu.Unlock() } return nil }) } _ = egReduce.Wait() if recoveryErr != nil { return recoveryErr } if err := wikiCheckpointError(ctx, requestbudget.Check(ctx)); recovery.Present() && err != nil && !errors.Is(err, errWikiCheckpointYield) { return err } if recovery.Present() && len(unappliedSlugKIDs) > 0 { return errors.New("page reduction incomplete") } tailCtx, tailCancel := wikiIngestCleanupContext(ctx) defer tailCancel() // Sanitize the doc summary pages produced by this batch BEFORE we // rebuild the index. The summary LLM (run during // map) was free to inject [[entity/foo|name]] links to every slug it // saw extracted, but reduce may have failed to materialize some of // those slugs into actual pages. Rewrite those dead links to plain // text so the summary doesn't contain unresolvable references. if len(failedAdditionSlugs) > 0 && len(docResults) > 0 { s.sanitizeDeadSummaryLinks(tailCtx, payload.KnowledgeBaseID, docResults, failedAdditionSlugs, batchCtx) } // Include receipts from earlier slices in publishing/finalization, including // a page saved just before a prior worker stopped before publishing it. for _, r := range docResults { if _, yielded := yieldedSlugKIDs[r.KnowledgeID]; yielded { continue } if _, failed := unappliedSlugKIDs[r.KnowledgeID]; failed { continue } for _, page := range r.Pages { allPagesAffected = appendUnique(allPagesAffected, page.Slug) } } totalPagesAffected = len(allPagesAffected) recoveryIncomplete := recovery.Present() && (len(failedOps) > 0 || len(unappliedSlugKIDs) > 0 || len(yieldedOps) > 0 || len(yieldedSlugKIDs) > 0) // A persisted draft is not completed until its publication succeeds. if len(allPagesAffected) > 0 && !recoveryIncomplete { logger.Infof(ctx, "wiki ingest: publishing draft pages") for slug, publishErr := range s.publishDraftPages(tailCtx, payload.KnowledgeBaseID, allPagesAffected) { if recovery.Present() && errors.Is(publishErr, ErrWikiRecoveryBudgetStopped) { return publishErr } if errors.Is(publishErr, apprepo.ErrWikiPageNotFound) { hasAddition := false for _, update := range slugUpdates[slug] { hasAddition = hasAddition || (update.Type != "retract" && update.Type != "retractStale") } if !hasAddition { continue } } collectUnapplied(slugUpdates[slug]) if recovery.Present() { return fmt.Errorf("%w: page publication incomplete: %w", ErrWikiRecoveryBudgetStopped, publishErr) } } } // Project one bounded summary into the knowledge-base activity feed. // Detailed per-document Wiki log rows duplicated that feed and were never // consumed by retrieval, so the activity record is now written directly. wikiActivityActions := make(map[string]int, 2) for _, op := range pendingOps { if op.Op == WikiOpRetract { wikiActivityActions["retract"]++ } } for _, r := range docResults { if r == nil { continue } _, yielded := yieldedSlugKIDs[r.KnowledgeID] _, failed := unappliedSlugKIDs[r.KnowledgeID] if !yielded && !failed { wikiActivityActions["ingest"]++ } } RecordWikiContentActivity(tailCtx, s.audit, payload.TenantID, payload.KnowledgeBaseID, wikiActivityActions) // Defer KB-global convergence (index-intro rebuild + dead-link cleanup + // cross-link injection) to a debounced per-KB wiki:finalize task instead of // running it at the tail of every 5-doc batch. We record what changed into // the finalize lane and schedule a coalesced trigger; a burst of N documents // then rebuilds the index ONCE. See ProcessWikiFinalize. // // freshTitleBySlug carries the (slug → title) pairs this batch successfully // wrote (minus reduce-phase failures) so finalize's cross-link pass can // linkify mentions of the new pages. freshTitleBySlug := make(map[string]string, len(docResults)*4) for _, dr := range docResults { if dr == nil { continue } for _, p := range dr.Pages { if p.Slug == "" || p.Title == "" { continue } if _, bad := failedAdditionSlugs[p.Slug]; bad { continue } if _, pending := unwrittenSlugs[p.Slug]; pending { continue } freshTitleBySlug[p.Slug] = p.Title } } if !recoveryIncomplete && len(yieldedSlugKIDs) == 0 && (len(allPagesAffected) > 0 || len(docResults) > 0 || retractHandled > 0 || len(retractFolderIDs) > 0) { changes := make([]wikiFinalizeChange, 0, len(docResults)+len(pendingOps)) for _, r := range docResults { if _, yielded := yieldedSlugKIDs[r.KnowledgeID]; yielded { continue } if _, failed := unappliedSlugKIDs[r.KnowledgeID]; failed { continue } changes = append(changes, wikiFinalizeChange{ Action: wikiFinalizeAdded, DocTitle: r.DocTitle, DocSummary: r.Summary, }) } for _, op := range pendingOps { if op.Op == WikiOpRetract { changes = append(changes, wikiFinalizeChange{ Action: wikiFinalizeRemoved, DocTitle: op.DocTitle, DocSummary: op.DocSummary, }) } } if err := s.enqueueFinalize(tailCtx, payload, allPagesAffected, freshTitleBySlug, changes, retractFolderIDs); err != nil { return err } } // Close postprocess.wiki spans for every successfully-mapped doc. // Span duration now spans map + reduce + index rebuild + cleanup + // cross-link injection + publish, matching the wall-clock window // the user thinks of as "wiki processing for this knowledge". // Per-doc page write outcomes are summarised in the output so the // trace viewer can show how many of the doc's extracted pages // actually landed (vs. dropped because reduce-phase generation // failed). failedAdditionSlugCount := len(failedAdditionSlugs) spanCtx, spanCancel := wikiIngestCleanupContext(ctx) for _, r := range docResults { if r == nil { continue } // A successfully-mapped doc is terminal for its wiki op, so // release the knowledge's slot in pending_subtasks_count (the row // promotes to completed once the counter hits zero). Done before // the WikiSpan nil-check below so a doc that had no attempt to // attach a span to still drains its counter slot. The matching +1 // is seeded by KnowledgePostProcess.SetFinalizing. // // EXCEPT docs with an unapplied slug (contended lock or reduce // error): those are re-queued below, so keep their finalizing slot // held — the retry (or the dead-letter drain in requeueFailedOps) // releases it once the op reaches a real terminal state. if _, unapplied := unappliedSlugKIDs[r.KnowledgeID]; !unapplied { if _, yielded := yieldedSlugKIDs[r.KnowledgeID]; !yielded { if !recovery.Present() { s.finalizeWikiSubtask(ctx, r.KnowledgeID) } } } if r.WikiSpan == nil { continue } writtenPages := make([]map[string]string, 0, len(r.Pages)) droppedPages := make([]map[string]string, 0) for _, p := range r.Pages { entry := map[string]string{ "slug": p.Slug, "title": previewText(p.Title, 80), } if _, bad := failedAdditionSlugs[p.Slug]; bad { droppedPages = append(droppedPages, entry) continue } writtenPages = append(writtenPages, entry) } output := types.JSONMap{ "pages_written": len(writtenPages), "pages_dropped": len(droppedPages), "pages_total": len(r.Pages), "failed_slug_writes": failedAdditionSlugCount, "pages_written_preview": writtenPages, } if len(droppedPages) > 0 { output["pages_dropped_preview"] = droppedPages } for k, v := range r.MapStats { output[k] = v } if _, unapplied := unappliedSlugKIDs[r.KnowledgeID]; unapplied { s.tracker().FailSpan(spanCtx, r.WikiSpan, "PAGE_WRITE_INCOMPLETE", "部分 Wiki 页面未写入,等待恢复", nil) } else if _, yielded := yieldedSlugKIDs[r.KnowledgeID]; yielded { s.tracker().SkipSpan(spanCtx, r.WikiSpan, "checkpoint_saved_continuation_pending") } else { s.tracker().EndSpan(spanCtx, r.WikiSpan, output) } } spanCancel() // Failed-map docs already had FailSpan called inside // mapOneDocument (the failedOps path returns before reaching // docResults). Nothing extra to do here for them. // Fold documents with an unapplied slug (contended lock or reduce error) // into failedOps so they are neither trimmed nor promoted to completed: // requeueFailedOps then runs them through the same fail_count budget // (retry now, dead-letter once the slug stays permanently hot/broken) as // a map-phase failure. A doc already counted as a map failure is skipped // to avoid a double fail_count bump. if len(unappliedSlugKIDs) > 0 { failedKIDs := make(map[string]struct{}, len(failedOps)) for _, op := range failedOps { failedKIDs[op.KnowledgeID] = struct{}{} } for _, op := range pendingOps { if _, unapplied := unappliedSlugKIDs[op.KnowledgeID]; !unapplied { continue } if _, already := failedKIDs[op.KnowledgeID]; already { continue } failedOps = append(failedOps, op) failedKIDs[op.KnowledgeID] = struct{}{} } } // Build the trim set: rows that should be removed from // task_pending_ops. We start from the full peekedIDs (every row we // pulled, even ones de-duplicated by knowledge_id) and subtract // any failed op's dbID — those need to stay in place so the // requeueFailedOps path can decide between retry and dead-letter. failedIDSet := make(map[int64]struct{}, len(failedOps)) for _, op := range pendingOps { if _, yielded := yieldedSlugKIDs[op.KnowledgeID]; yielded { if _, failed := unappliedSlugKIDs[op.KnowledgeID]; !failed { yieldedOps = append(yieldedOps, op) } } } var yieldedIDs []int64 for _, op := range yieldedOps { if op.dbID != 0 { failedIDSet[op.dbID] = struct{}{} yieldedIDs = append(yieldedIDs, op.dbID) } } for _, op := range failedOps { if op.dbID != 0 { failedIDSet[op.dbID] = struct{}{} } } trimIDs := make([]int64, 0, len(peekedIDs)) for _, id := range peekedIDs { if _, fail := failedIDSet[id]; fail { continue } trimIDs = append(trimIDs, id) } settleCtx, settleCancel := wikiIngestCleanupContext(ctx) trimErr := s.trimPendingList(settleCtx, trimIDs) // Process failed ops: increment fail_count and dead-letter once // the cap is hit. Must come AFTER trim so successful siblings are // already gone from the queue — otherwise a follow-up batch could // re-pick them up. var requeueErr error if len(failedOps) > 0 { requeueErr = s.requeueFailedOps(settleCtx, payload, failedOps) } var yieldErr error if len(yieldedIDs) > 0 { yieldErr = s.pendingRepo.ReleaseByIDs(settleCtx, yieldedIDs) } settleCancel() if err := errors.Join(trimErr, requeueErr, yieldErr); err != nil { exitStatus = "settle_failed" return fmt.Errorf("wiki ingest: settle claimed rows: %w", err) } // All claimed rows have now reached a terminal state (deleted on success, // released for retry, or dead-lettered), so disarm the abnormal-exit // release net. claimsSettled = true logger.Infof(ctx, "wiki ingest: batch completed for KB %s, %d ops, %d pages affected", payload.KnowledgeBaseID, len(pendingOps), len(allPagesAffected)) // Pace the follow-up: on a rate-limit trip, back off so the per-minute // window can reset instead of retrying the failed docs immediately. followUpDelay := wikiFollowUpDelay if rateLimited { followUpDelay = wikiRateLimitBackoff logger.Warnf(ctx, "wiki ingest: KB %s hit upstream rate limiting, backing off follow-up to %s", payload.KnowledgeBaseID, followUpDelay) } followCtx, followCancel := wikiIngestCleanupContext(ctx) followUpScheduled = s.scheduleFollowUp(followCtx, payload, followUpDelay) followCancel() return nil } // ProcessWikiFinalize runs the debounced, per-KB KB-global convergence pass: // index-intro rebuild, dead-link cleanup, and cross-link injection. It drains // the finalize lane of task_pending_ops (written by ProcessWikiIngest via // enqueueFinalize) so a burst of documents rebuilds the index ONCE instead of // once per 5-doc batch. func (s *wikiIngestService) ProcessWikiFinalize(ctx context.Context, t *asynq.Task) (retErr error) { startedAt := time.Now() var payload WikiIngestPayload if err := json.Unmarshal(t.Payload(), &payload); err != nil { return fmt.Errorf("wiki finalize: unmarshal payload: %w", err) } ctx = context.WithValue(ctx, types.TenantIDContextKey, payload.TenantID) if payload.Language != "" { ctx = context.WithValue(ctx, types.LanguageContextKey, payload.Language) } if s.pendingRepo == nil { return nil } // Per-KB finalize lock, separate from the ingest active lock so finalize // and ingest batches never block each other. Coalescing via asynq.TaskID // already keeps at most one finalize pending per KB; this guards the // reschedule-overlap window against a concurrent index-page write. if s.redisClient != nil { key := "wiki:finalize:active:" + payload.KnowledgeBaseID acquired, err := s.redisClient.SetNX(ctx, key, "1", wikiFinalizeLockTTL).Result() if err != nil { // Fail CLOSED: proceeding unlocked would let two finalize runs // drain the same PeekBatch rows and double-rebuild the index page // (GetPage→modify→UpdatePage lost update). Return an error so // asynq retries the (coalesced) finalize once Redis recovers, // instead of racing a concurrent finalize. logger.Warnf(ctx, "wiki finalize: SetNX failed for KB %s: %v (retrying)", payload.KnowledgeBaseID, err) return fmt.Errorf("wiki finalize: acquire lock: %w", err) } else if !acquired { // Another finalize is running; it will drain the lane and // reschedule if rows remain. Safe to no-op. return nil } lockCtx, cancelLock := context.WithCancel(context.Background()) defer func() { cancelLock() s.redisClient.Del(context.Background(), key) }() go func() { ticker := time.NewTicker(wikiFinalizeLockRenew) defer ticker.Stop() for { select { case <-lockCtx.Done(): return case <-ticker.C: s.redisClient.Expire(context.Background(), key, wikiFinalizeLockTTL) } } }() } else { if _, loaded := s.liteFinalizeLocks.LoadOrStore(payload.KnowledgeBaseID, struct{}{}); loaded { return nil } defer s.liteFinalizeLocks.Delete(payload.KnowledgeBaseID) } rows, err := s.pendingRepo.PeekBatch(ctx, wikiFinalizeTaskType, wikiTaskScope, payload.KnowledgeBaseID, wikiFinalizeMaxRows) if err != nil { return fmt.Errorf("wiki finalize: peek: %w", err) } if len(rows) == 0 { return nil } rows, recovery, err := selectWikiFinalizeGroup(rows) defer func() { retErr = s.settleWikiRecoveryStop(ctx, recovery, payload.TenantID, retErr) }() if err != nil { return err } if !recovery.Present() { if err := s.rejectOrdinaryFinalizeRecoveryOverlap(ctx, payload); err != nil { return err } } kb, err := s.kbService.GetKnowledgeBaseByIDOnly(ctx, payload.KnowledgeBaseID) if errors.Is(err, apprepo.ErrKnowledgeBaseNotFound) || (err == nil && kb == nil) { if recovery.Present() { return fmt.Errorf("%w: knowledge base unavailable", ErrWikiRecoveryBudgetStopped) } if cleanupErr := s.clearDeletedKnowledgeBasePendingOps(ctx, payload.KnowledgeBaseID); cleanupErr != nil { return fmt.Errorf("wiki finalize: clear deleted KB queue: %w", cleanupErr) } return nil } if err != nil { return fmt.Errorf("wiki finalize: get KB: %w", err) } synthesisModelID := "" if kb.WikiConfig != nil { synthesisModelID = kb.WikiConfig.SynthesisModelID } if synthesisModelID == "" { synthesisModelID = kb.SummaryModelID } var recoveryCancel context.CancelFunc ctx, recoveryCancel, err = s.bindWikiRecovery(ctx, payload, recovery, "", synthesisModelID) defer recoveryCancel() if err != nil { return err } // Aggregate the drained rows into: affected slugs (dedup), fresh cross-link // refs, and the index-intro change description. We collect ids up front so // we can drain the lane even on the KB-disabled short-circuit below. ids := make([]int64, 0, len(rows)) pruneRowIDs := make([]int64, 0) affectedSet := make(map[string]struct{}, len(rows)) var affectedSlugs []string var freshRefs []linkRef var folderPruneIDs []string var changeDesc strings.Builder for _, r := range rows { ids = append(ids, r.ID) if r.Op == wikiFinalizeOpFolderPrune { pruneRowIDs = append(pruneRowIDs, r.ID) } if len(r.Payload) == 0 { continue } var row wikiFinalizeRow if err := json.Unmarshal(r.Payload, &row); err != nil { if recovery.Present() { return fmt.Errorf("%w: invalid finalize content: %v", ErrWikiRecoveryBudgetStopped, err) } logger.Warnf(ctx, "wiki finalize: unmarshal row id=%d failed: %v", r.ID, err) continue } if r.Op == wikiFinalizeOpFolderPrune { folderPruneIDs = append(folderPruneIDs, row.FolderIDs...) continue } if row.Change != nil { if row.Change.Action == wikiFinalizeRemoved { fmt.Fprintf(&changeDesc, "\n%s\n%s\n\n\n", row.Change.DocTitle, row.Change.DocSummary) } else { fmt.Fprintf(&changeDesc, "\n%s\n%s\n\n\n", row.Change.DocTitle, row.Change.DocSummary) } continue } if row.Slug != "" { if _, ok := affectedSet[row.Slug]; !ok { affectedSet[row.Slug] = struct{}{} affectedSlugs = append(affectedSlugs, row.Slug) } if row.Title != "" { freshRefs = append(freshRefs, linkRef{slug: row.Slug, matchText: row.Title}) } } } // KB flipped away from wiki (deleted / type change) — drain the lane so the // rows don't accumulate, then stop. if !kb.IsWikiEnabled() { drainCtx, drainCancel := wikiIngestCleanupContext(ctx) err := s.trimPendingList(drainCtx, ids) drainCancel() if err != nil { return fmt.Errorf("wiki finalize: trim disabled lane: %w", err) } return nil } if synthesisModelID == "" { // No model to rebuild the index with; still run the pure-text passes, // then drain. Missing model is a config gap, not a transient error. logger.Warnf(ctx, "wiki finalize: no synthesis model for KB %s, skipping index rebuild", payload.KnowledgeBaseID) } batchCtx := s.newWikiBatchContext(payload.KnowledgeBaseID, kb.WikiConfig) lang := types.LanguageNameFromContext(ctx) indexRebuilt := false if changeDesc.Len() > 0 && synthesisModelID != "" { chatModel, mErr := s.modelService.GetChatModel(ctx, synthesisModelID) if mErr != nil { if recovery.Present() { return mErr } logger.Warnf(ctx, "wiki finalize: get chat model failed: %v", mErr) } else if err := s.rebuildIndexPage(ctx, chatModel, payload, changeDesc.String(), lang, batchCtx.ContentInstructions); err != nil { if recovery.Present() { return wikiCheckpointError(ctx, err) } logger.Warnf(ctx, "wiki finalize: rebuild index failed: %v", err) } else { indexRebuilt = true } } if err := requestbudget.Check(ctx); recovery.Present() && err != nil { return err } if len(affectedSlugs) > 0 { if err := s.cleanDeadLinks(ctx, payload.KnowledgeBaseID, affectedSlugs, batchCtx); err != nil && recovery.Present() { return err } if err := s.injectCrossLinks(ctx, payload.KnowledgeBaseID, affectedSlugs, freshRefs, batchCtx); err != nil && recovery.Present() { return err } } // A retract may leave one or more generated folders empty. Do not prune // while any ingest row for this KB is queued or claimed: taxonomy planning // creates folders before reduce writes pages, so an apparently-empty folder // can still be owned by an in-flight batch. The durable prune rows stay in // the finalize lane and are retried after the ingest lane drains. pruneDeferred := false deletedFolders := 0 if len(folderPruneIDs) > 0 { pending, pErr := s.pendingRepo.PendingCount(ctx, wikiTaskType, wikiTaskScope, payload.KnowledgeBaseID) if pErr != nil { logger.Warnf(ctx, "wiki finalize: cannot verify ingest drain before folder prune: %v", pErr) pruneDeferred = true } else if pending > 0 { pruneDeferred = true } else { deleted, pruneErr := s.wikiService.PruneEmptyFolderChains( ctx, payload.KnowledgeBaseID, uniqueWikiFolderIDs(folderPruneIDs)) if pruneErr != nil { logger.Warnf(ctx, "wiki finalize: prune empty folders failed: %v", pruneErr) pruneDeferred = true } else { deletedFolders = len(deleted) } } } // Drain the processed rows. Best-effort convergence mirrors the legacy // in-batch behaviour: a failed index rebuild is logged (not retried), // so we delete regardless to avoid re-doing the whole pass forever. idsToTrim := ids if pruneDeferred && len(pruneRowIDs) > 0 { deferred := make(map[int64]struct{}, len(pruneRowIDs)) for _, id := range pruneRowIDs { deferred[id] = struct{}{} } idsToTrim = idsToTrim[:0:0] for _, id := range ids { if _, keep := deferred[id]; !keep { idsToTrim = append(idsToTrim, id) } } } drainCtx, drainCancel := wikiIngestCleanupContext(ctx) if recovery.Present() { guard := wikiRecoveryFromContext(ctx) var completed bool if err = guard.Check(drainCtx); err == nil { completed, err = guard.repo.Complete(drainCtx, guard.row, idsToTrim) } if err == nil && completed { s.finalizeWikiSubtask(drainCtx, guard.row.KnowledgeID) } } else { err = s.trimPendingList(drainCtx, idsToTrim) } drainCancel() if err != nil { return fmt.Errorf("wiki finalize: trim pending rows: %w", err) } // If more finalize rows landed while we were working, reschedule so they // get their own convergence pass. rescheduled := false if pruneDeferred { s.scheduleFinalizeRetry(ctx, payload) rescheduled = true } if n, cErr := s.pendingRepo.PendingCount(ctx, wikiFinalizeTaskType, wikiTaskScope, payload.KnowledgeBaseID); cErr == nil && n > 0 { if !pruneDeferred { s.scheduleFinalize(ctx, payload) } rescheduled = true } logger.Infof(ctx, "wiki finalize: kb=%s rows=%d affected_slugs=%d deleted_folders=%d folder_prune_deferred=%v index_rebuilt=%v rescheduled=%v elapsed=%s", payload.KnowledgeBaseID, len(rows), len(affectedSlugs), deletedFolders, pruneDeferred, indexRebuilt, rescheduled, time.Since(startedAt).Round(time.Millisecond), ) return nil } func (s *wikiIngestService) mapOneDocument( ctx context.Context, chatModel chat.Chat, payload WikiIngestPayload, op WikiPendingOp, batchCtx *WikiBatchContext, ) (*docIngestResult, []SlugUpdate, error) { docStartedAt := time.Now() knowledgeID := op.KnowledgeID lang := types.ResolveLanguageName(ctx, op.Language) // Open a postprocess.wiki subspan under the parent attempt's // postprocess stage so the actual per-doc work (LLM extraction + // summary + classification) shows up in the trace tree. Returns // nil when the parent attempt is gone (no panic on missing // lookups — span tracker is best-effort). wikiSpan := s.beginWikiSubspan(ctx, knowledgeID, types.JSONMap{ "language": lang, "knowledge_base_id": payload.KnowledgeBaseID, }) // 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. gone, sourceErr := s.isKnowledgeGone(ctx, payload.KnowledgeBaseID, knowledgeID) if sourceErr != nil { s.tracker().FailSpan(ctx, wikiSpan, "SOURCE_CHECK_FAILED", sourceErr.Error(), sourceErr) return nil, nil, wikiCheckpointError(ctx, sourceErr) } if gone { if ctx.Err() != nil { return nil, nil, wikiCheckpointError(ctx, ctx.Err()) } logger.Infof(ctx, "wiki ingest: knowledge %s has been deleted, skip map", knowledgeID) s.tracker().SkipSpan(ctx, wikiSpan, "knowledge_deleted") return nil, nil, nil } chunks, err := s.chunkRepo.ListChunksByKnowledgeID(ctx, payload.TenantID, knowledgeID) if err != nil { s.tracker().FailSpan(ctx, wikiSpan, "LIST_CHUNKS_FAILED", err.Error(), err) return nil, nil, fmt.Errorf("get chunks: %w", err) } if len(chunks) == 0 { logger.Infof(ctx, "wiki ingest: document %s has no chunks, skip", knowledgeID) s.tracker().SkipSpan(ctx, wikiSpan, "no_chunks") return nil, nil, nil } content := reconstructEnrichedContent(ctx, s.chunkRepo, payload.TenantID, chunks) rawRuneCount := utf8.RuneCountInString(content) logger.Infof(ctx, "wiki ingest: doc %s chunks=%d source_chars=%d read_windows=%d", knowledgeID, len(chunks), rawRuneCount, len(splitWikiReadWindows(content, maxContentForWiki))) // Refuse to run LLM-based extraction when the document carries no real // text — e.g. a scanned PDF whose pages were converted to images but where // VLM OCR produced nothing usable. Without this guard the LLM would have // only image markup left and would happily fabricate entities/concepts. if !hasSufficientTextContent(content) { logger.Warnf(ctx, "wiki ingest: doc %s has insufficient text content after stripping image markup (raw_len=%d), skipping LLM extraction", knowledgeID, rawRuneCount, ) s.tracker().SkipSpan(ctx, wikiSpan, "insufficient_text_content") return nil, nil, nil } docTitle := knowledgeID if kn, err := s.knowledgeSvc.GetKnowledgeByIDOnly(ctx, knowledgeID); err == nil && kn != nil && kn.Title != "" { docTitle = kn.Title } else { for _, ch := range chunks { if ch.Content != "" { lines := strings.SplitN(ch.Content, "\n", 2) if len(lines) > 0 && len(lines[0]) > 0 && len(lines[0]) < 200 { docTitle = strings.TrimPrefix(strings.TrimSpace(lines[0]), "# ") break } } } } // Citation source reference. We deliberately use only the knowledge ID // (not docTitle, which is typically the upload filename) so the filename // does not leak into citation strings that downstream LLM prompts may // surface during wiki page editing. sourceRef := knowledgeID scope, scopeErr := s.newWikiCheckpointScope(ctx, chatModel, payload, op, chunks, content, docTitle, lang, batchCtx) if scopeErr != nil { s.tracker().FailSpan(ctx, wikiSpan, "CHECKPOINT_FAILED", scopeErr.Error(), scopeErr) return nil, nil, scopeErr } if scope != nil { batchCtx.checkpointScopes.Store(knowledgeID, scope) batchCtx.checkpointSourceHashes.Store(knowledgeID, wikiEvidenceSourceHash(chunks)) } var savedMap wikiMapCheckpoint if hit, loadErr := s.loadWikiCheckpoint(ctx, scope, "map", "complete", &savedMap); loadErr != nil { s.tracker().FailSpan(ctx, wikiSpan, "CHECKPOINT_FAILED", loadErr.Error(), loadErr) return nil, nil, loadErr } else if hit { if err := validateWikiMapCheckpoint(savedMap, scope, chunks); err != nil { s.tracker().FailSpan(ctx, wikiSpan, "CHECKPOINT_FAILED", err.Error(), err) return nil, nil, err } savedMap.Result.WikiSpan = wikiSpan return &savedMap.Result, savedMap.Updates, nil } oldPageSlugs := s.getExistingPageSlugsForKnowledge(ctx, payload.KnowledgeBaseID, knowledgeID) // Pass 0: lightweight candidate slug extraction (skeleton only). // Invalid output gets one bounded split retry inside extraction. Provider // failures must not start another extractor or skip citation coverage. var ( extractedEntities []extractedItem extractedConcepts []extractedItem slugItems map[string]extractedItem ) logger.Infof(ctx, "wiki ingest: pass 0 — extracting candidate slugs for %s", knowledgeID) extractSpan := s.tracker().BeginSubSpan(ctx, wikiSpan, "postprocess.wiki.extract", types.SpanKindSubSpan, types.JSONMap{ "content_chars": utf8.RuneCountInString(content), "old_pages": len(oldPageSlugs), }) var candidates combinedExtraction hit, err := s.loadWikiCheckpoint(ctx, scope, "candidates", "complete", &candidates) if err == nil && hit { candidates, err = pruneAndValidateWikiCandidates(ctx, candidates, content) } if err == nil && !hit { extractedEntities, extractedConcepts, slugItems, err = s.extractWikiFullCandidates(ctx, chatModel, payload.KnowledgeBaseID, content, lang, oldPageSlugs, batchCtx, scope) candidates = combinedExtraction{Entities: extractedEntities, Concepts: extractedConcepts} if err == nil { candidates, err = pruneAndValidateWikiCandidates(ctx, candidates, content) } if err == nil { err = s.saveWikiCheckpoint(ctx, scope, "candidates", "complete", candidates) } } else if err == nil { extractedEntities, extractedConcepts = candidates.Entities, candidates.Concepts slugItems = make(map[string]extractedItem) for _, item := range append(append([]extractedItem(nil), extractedEntities...), extractedConcepts...) { slugItems[item.Slug] = item } } if err != nil { if errors.Is(err, errWikiCheckpointYield) { s.tracker().SkipSpan(ctx, extractSpan, "checkpoint_saved_continuation_pending") s.tracker().SkipSpan(ctx, wikiSpan, "checkpoint_saved_continuation_pending") return nil, nil, err } s.tracker().FailSpan(ctx, extractSpan, "EXTRACT_FAILED", err.Error(), err) s.tracker().FailSpan(ctx, wikiSpan, "EXTRACT_FAILED", err.Error(), err) return nil, nil, err } s.tracker().EndSpan(ctx, extractSpan, types.JSONMap{ "entities": len(extractedEntities), "concepts": len(extractedConcepts), "pass0_fallback": false, "entities_preview": previewExtractedItems(extractedEntities, 8), "concepts_preview": previewExtractedItems(extractedConcepts, 8), }) citationPlan, err := buildWikiCitationPlan(extractedEntities, extractedConcepts, chunks, lang) if err != nil { s.tracker().FailSpan(ctx, wikiSpan, "CITATION_PLAN_FAILED", err.Error(), err) return nil, nil, err } // Build slug listing for Summary's wiki-link input. var summaryExtractedPages []string for slug := range slugItems { summaryExtractedPages = append(summaryExtractedPages, slug) } sort.Strings(summaryExtractedPages) // Wiki summary slug is derived from the knowledge ID rather than the // docTitle (which is typically the upload filename). Filename-based slugs // like "summary/mx5280-pdf" expose the filename in cross-link contexts // that downstream LLM prompts read; a UUID-based slug is uglier but // hallucination-safe. summarySlug := fmt.Sprintf("summary/%s", slugify(knowledgeID)) var slugListing string for _, slug := range summaryExtractedPages { if item, ok := slugItems[slug]; ok { aliases := "" if len(item.Aliases) > 0 { aliases = fmt.Sprintf(" (Aliases: %s)", strings.Join(item.Aliases, ", ")) } slugListing += fmt.Sprintf("- [[%s]] = %s%s\n", slug, item.Name, aliases) } else { slugListing += fmt.Sprintf("- [[%s]]\n", slug) } } // Summary and chunk classification are independent given Pass 0 output — // run them in parallel. Summary handles wiki-link injection; classification // attaches concrete chunk IDs to each candidate slug. var ( summaryContent string summaryErr error citations map[string][]string newSlugs []newSlugFromCitation batchCount int classifyErr error ) // Both calls run in parallel goroutines under the same wikiSpan // parent — their subspans will visually overlap in the trace view, // which correctly reflects their wall-clock concurrency. summarySpan := s.tracker().BeginSubSpan(ctx, wikiSpan, "postprocess.wiki.summary", types.SpanKindSubSpan, types.JSONMap{ "content_chars": utf8.RuneCountInString(content), "extracted_slugs": len(summaryExtractedPages), }) classifySpan := s.tracker().BeginSubSpan(ctx, wikiSpan, "postprocess.wiki.classify", types.SpanKindSubSpan, types.JSONMap{ "chunks": len(chunks), "candidates": len(extractedEntities) + len(extractedConcepts), "candidate_shards": len(citationPlan.Shards), "source_batches": len(citationPlan.Sources), "planned_batches": len(citationPlan.Shards) * len(citationPlan.Sources), "catalog_plan_hash": citationPlan.Hash, }) runSummary := func() { summaryKey := wikiCheckpointHash(slugListing) hit, loadErr := s.loadWikiCheckpoint(ctx, scope, "summary", summaryKey, &summaryContent) summaryErr = loadErr if summaryErr == nil && hit && strings.TrimSpace(summaryContent) == "" { summaryErr = errors.New("empty persisted wiki summary") } if summaryErr == nil && !hit { summaryContent, summaryErr = s.generateWikiFullSummary(ctx, chatModel, content, lang, slugListing, batchCtx, scope) if summaryErr == nil && strings.TrimSpace(summaryContent) == "" { summaryErr = errors.New("empty wiki summary") } if summaryErr == nil { summaryErr = s.saveWikiCheckpoint(ctx, scope, "summary", summaryKey, summaryContent) } } if summaryErr != nil { if errors.Is(summaryErr, errWikiCheckpointYield) { s.tracker().SkipSpan(ctx, summarySpan, "checkpoint_saved_continuation_pending") return } s.tracker().FailSpan(ctx, summarySpan, "SUMMARY_FAILED", summaryErr.Error(), summaryErr) } else { sumLine, sumBody := splitSummaryLine(summaryContent) s.tracker().EndSpan(ctx, summarySpan, types.JSONMap{ "chars": utf8.RuneCountInString(summaryContent), "summary_line": previewText(sumLine, 160), "body_preview": previewText(sumBody, 320), }) } } runClassification := func() { citations, newSlugs, batchCount, classifyErr = s.classifyWikiCitationPlan(ctx, chatModel, citationPlan, chunks, lang, scope) if classifyErr != nil { if errors.Is(classifyErr, errWikiCheckpointYield) { s.tracker().SkipSpan(ctx, classifySpan, "checkpoint_saved_continuation_pending") return } s.tracker().FailSpan(ctx, classifySpan, "CITATION_FAILED", classifyErr.Error(), classifyErr) return } s.tracker().EndSpan(ctx, classifySpan, types.JSONMap{ "cited_slugs": len(citations), "new_slugs": len(newSlugs), "batches": batchCount, "top_cited": topCitedSlugs(citations, 8), "new_slugs_sample": previewNewSlugs(newSlugs, 8), }) } if rawRuneCount > maxContentForWiki || utf8.RuneCountInString(slugListing) > wikiReadCandidateRunes { // A paused/yielded long-summary stage must not launch more paid // citation work on every retry. Finish its bounded, resumable work // first; source citations remain an independent evidence stage. runSummary() if summaryErr == nil { runClassification() } else { s.tracker().SkipSpan(ctx, classifySpan, "waiting_for_full_source_summary") } } else { var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done(); runSummary() }() go func() { defer wg.Done(); runClassification() }() wg.Wait() } if mapErr := wikiCheckpointMapError(ctx, summaryErr, classifyErr); mapErr != nil { if errors.Is(mapErr, errWikiCheckpointYield) { s.tracker().SkipSpan(ctx, wikiSpan, "checkpoint_saved_continuation_pending") } else { s.tracker().FailSpan(ctx, wikiSpan, "MAP_FAILED", mapErr.Error(), mapErr) } return nil, nil, mapErr } // Merge citations back into the item structs. Uncited items retain navigation // metadata but cannot pass the source-evidence gate for page publication. var uncited int extractedEntities, extractedConcepts, uncited, err = mergeWikiSourceCitationItems(extractedEntities, extractedConcepts, citations, newSlugs) if err != nil { s.tracker().FailSpan(ctx, wikiSpan, "SOURCE_IDENTITY_FAILED", err.Error(), err) return nil, nil, err } resolved, err := s.resolveWikiSourceIdentities(ctx, payload.KnowledgeBaseID, combinedExtraction{Entities: extractedEntities, Concepts: extractedConcepts}, batchCtx) if err != nil { s.tracker().FailSpan(ctx, wikiSpan, "SOURCE_IDENTITY_FAILED", err.Error(), err) return nil, nil, err } extractedEntities, extractedConcepts = resolved.Entities, resolved.Concepts // Rebuild slugItems so stale entries (for slugs that did not survive the // merge) and brand-new slugs discovered by the citation pass are both // reflected in summaryExtractedPages tracking. slugItems = make(map[string]extractedItem, len(extractedEntities)+len(extractedConcepts)) for _, item := range extractedEntities { if item.Slug != "" && item.Name != "" { slugItems[item.Slug] = item } } for _, item := range extractedConcepts { if item.Slug != "" && item.Name != "" { slugItems[item.Slug] = item } } // extractedPages records every wiki page this document materialized // (entities, concepts, plus the summary page appended below). The // slug is used for link/retract bookkeeping; the title is retained for // trace output and finalize processing. extractedPages := make([]wikiIngestPageRef, 0, len(slugItems)+1) for slug, item := range slugItems { title := item.Name if title == "" { title = slug } extractedPages = append(extractedPages, wikiIngestPageRef{Slug: slug, Title: title}) } // Count total distinct chunks cited across all slugs for logging. citedChunkSet := make(map[string]bool) for _, ids := range citations { for _, id := range ids { citedChunkSet[id] = true } } var updates []SlugUpdate // docSummaryLine is the one-sentence headline used for terse log/audit // previews and for blocks in retract prompts. // docSummary is the full summary body attached to each entity/concept // update so the editor model gets rich framing in . var docSummaryLine string var docSummary string if summaryErr != nil { // Summary is the headline artifact of an ingested document — a // document with no summary page is half-ingested and leaves the // entity/concept updates hanging without a root to link back to // from the index. Historically we just logged and moved on, // which meant a single transient 504 permanently dropped the // summary page for that document. // // Returning an error here sends the op to failedOps (see the // map-phase loop in ProcessWikiIngest), which requeueFailedOps // appends back onto the pending list so the next batch retries. // The internal retries in generateWithTemplate already exhaust // the LLM's own transient-error budget before we give up here. logger.Errorf(ctx, "wiki ingest: generate summary failed for %s, will requeue: %v", knowledgeID, summaryErr) s.tracker().FailSpan(ctx, wikiSpan, "SUMMARY_FAILED", summaryErr.Error(), summaryErr) return nil, nil, fmt.Errorf("generate summary: %w", summaryErr) } sumLine, sumBody := splitSummaryLine(summaryContent) if sumBody == "" { sumBody = summaryContent } if sumLine == "" { sumLine = docTitle } docSummaryLine = sumLine docSummary = sumBody if strings.TrimSpace(docSummary) == "" { docSummary = sumLine } updates = append(updates, SlugUpdate{ Slug: summarySlug, Type: types.WikiPageTypeSummary, DocTitle: docTitle, KnowledgeID: knowledgeID, SourceRef: sourceRef, Language: lang, SummaryLine: sumLine, SummaryBody: sumBody, }) extractedPages = append(extractedPages, wikiIngestPageRef{Slug: summarySlug, Title: docTitle}) // Entities for _, item := range extractedEntities { if item.Slug != "" { updates = append(updates, SlugUpdate{ Slug: item.Slug, Type: types.WikiPageTypeEntity, Item: item, DocTitle: docTitle, KnowledgeID: knowledgeID, SourceRef: sourceRef, Language: lang, SourceChunks: item.SourceChunks, DocSummary: docSummary, }) } } // Concepts for _, item := range extractedConcepts { if item.Slug != "" { updates = append(updates, SlugUpdate{ Slug: item.Slug, Type: types.WikiPageTypeConcept, Item: item, DocTitle: docTitle, KnowledgeID: knowledgeID, SourceRef: sourceRef, Language: lang, SourceChunks: item.SourceChunks, DocSummary: docSummary, }) } } // Reconcile old page set against new extraction. // // Three cases: // // (a) oldSlug ∉ new → "retractStale": the doc no longer mentions this // page's subject, so strip its ref (and possibly delete the page // if this was the only source). Passes the NEW content as the // retract context — if the LLM finds matching facts it trims // them, otherwise the retract is a near no-op, which is fine. // // (b) oldSlug ∈ new AND slug is an entity/concept page → reparse // swap: emit BOTH a "retract" (carrying the doc's PRIOR summary // body as the old-version signal) AND the normal addition. The // reduce stage sees HasAdditions=1 + HasRetractions=1 and the // WikiPageModifyUserPrompt correctly tells the editor model to // remove the old K section and add the new K section in one // pass — giving us replace-not-append semantics that "append // new K on top of old K" would otherwise violate. // // (c) oldSlug ∈ new AND slug is a summary page (summary/...) → // nothing to do here. reduceSlugUpdates' summary branch // unconditionally overwrites the whole page from the new // SummaryBody, so emitting an extra retract would just be // dead weight that the summary branch discards anyway. // // priorContribution is the doc's LAST summary body, fetched lazily // at this point (rather than pre-loaded into the batch context). // Empty on first-ever ingest — in that case oldPageSlugs is also // empty, so we never consult it. priorContribution := batchCtx.SummaryContentByKnowledgeID(ctx, knowledgeID) newSlugSet := make(map[string]bool, len(extractedPages)) for _, ns := range extractedPages { newSlugSet[ns.Slug] = true } var reparseOverlap, staleCount int for oldSlug := range oldPageSlugs { if newSlugSet[oldSlug] { // Skip summary slugs — they're overwritten wholesale by the // summary update, retract would be ignored downstream. if strings.HasPrefix(oldSlug, "summary/") { continue } reparseOverlap++ updates = append(updates, SlugUpdate{ Slug: oldSlug, Type: "retract", RetractDocContent: priorContribution, DocTitle: docTitle, KnowledgeID: knowledgeID, Language: lang, }) continue } staleCount++ updates = append(updates, SlugUpdate{ Slug: oldSlug, Type: "retractStale", // Context for removing this source's stale contribution, never // evidence for new facts. Full raw documents can exceed context. RetractDocContent: docSummary, DocTitle: docTitle, KnowledgeID: knowledgeID, Language: lang, }) } logger.Infof(ctx, "wiki ingest: mapped knowledge %s title=%q candidates=%d chunks=%d batches=%d cited_chunks=%d uncited_slugs=%d new_slugs=%d updates=%d reparse_slugs=%d stale_slugs=%d pass0_fallback=%v elapsed=%s", knowledgeID, previewText(docTitle, 80), len(slugItems), len(chunks), batchCount, len(citedChunkSet), uncited, len(newSlugs), len(updates), reparseOverlap, staleCount, false, time.Since(docStartedAt).Round(time.Millisecond), ) // Map-phase metrics get attached to the postprocess.wiki span's // output, but we do NOT EndSpan here — the batch driver keeps the // span open through reduce + index rebuild + cross-link injection // + page publish, then closes it once this doc's pages have all // been written. That way the span's duration reflects the full // "wiki processing for this knowledge" time the user sees in the // trace viewer, not just the LLM extraction slice. mapStats := types.JSONMap{ "source_runes": rawRuneCount, "source_windows": len(splitWikiReadWindows(content, maxContentForWiki)), "source_read_complete": true, "summary_is_compressed": rawRuneCount > maxContentForWiki, "read_coverage_version": wikiFullReadVersion, "doc_title": previewText(docTitle, 120), "chunks": len(chunks), "candidate_slugs": len(slugItems), "cited_chunks": len(citedChunkSet), "uncited_slugs": uncited, "new_slugs": len(newSlugs), "updates": len(updates), "reparse_slugs": reparseOverlap, "stale_slugs": staleCount, "extracted_pages": len(extractedPages), "summary_chars": utf8.RuneCountInString(docSummary), "pass0_fallback": false, "classify_batches": batchCount, "summary_preview": previewText(docSummaryLine, 160), } result := &docIngestResult{ KnowledgeID: knowledgeID, DocTitle: docTitle, Summary: docSummaryLine, Pages: extractedPages, MapStats: mapStats, WikiSpan: wikiSpan, } persisted := *result persisted.WikiSpan = nil saved := wikiMapCheckpoint{Result: persisted, Updates: updates} if scope != nil { if err := validateWikiMapCheckpoint(saved, scope, chunks); err != nil { s.tracker().FailSpan(ctx, wikiSpan, "CHECKPOINT_FAILED", err.Error(), err) return nil, nil, err } } if err := s.saveWikiCheckpoint(ctx, scope, "map", "complete", saved); err != nil { s.tracker().FailSpan(ctx, wikiSpan, "CHECKPOINT_FAILED", err.Error(), err) return nil, nil, err } return result, updates, nil } func (s *wikiIngestService) extractEntitiesAndConceptsNoUpsert( ctx context.Context, chatModel chat.Chat, kbID string, content, lang string, oldPageSlugs map[string]bool, batchCtx *WikiBatchContext, ) ([]extractedItem, []extractedItem, map[string]extractedItem, error) { // Only entity/* and concept/* slugs are relevant for LLM slug-continuity — // summary slugs are code-generated from the knowledge ID and never appear // in the extraction output, so including them just wastes tokens and risks // confusing the model. var prevSlugsText string if len(oldPageSlugs) > 0 { var sb strings.Builder for slug := range oldPageSlugs { if !strings.HasPrefix(slug, "entity/") && !strings.HasPrefix(slug, "concept/") { continue } fmt.Fprintf(&sb, "- %s\n", slug) } prevSlugsText = sb.String() } if prevSlugsText == "" { prevSlugsText = "(none — this is a new document)" } result, err := s.generateWikiExtraction(ctx, chatModel, agent.WikiKnowledgeExtractPrompt, map[string]string{ "Content": content, "Language": lang, "PreviousSlugs": prevSlugsText, "CustomInstructions": batchCtx.ExtractionInstructions, "InstructionScope": "wiki_extraction", }) if err != nil { return nil, nil, nil, fmt.Errorf("combined extraction failed: %w", err) } result, err = s.resolveWikiSourceIdentities(ctx, kbID, result, batchCtx) if err != nil { return nil, nil, nil, err } slugItems := make(map[string]extractedItem) for _, item := range result.Entities { if item.Slug != "" && item.Name != "" { slugItems[item.Slug] = item } } for _, item := range result.Concepts { if item.Slug != "" && item.Name != "" { slugItems[item.Slug] = item } } return result.Entities, result.Concepts, slugItems, nil } // resolveSlugUpdateLanguage picks the language the editor prompt should write // the page in. // // A single page aggregates updates from every document that cites it, and an // update queued before the language field existed — or enqueued from a // background path that never saw the HTTP language middleware — carries none. // Scanning for the first update that resolved a language, rather than indexing // into one bucket, keeps the page localised as long as ANY contributor knew the // language; the deployment default covers the case where none did. Without the // fallback the prompt renders "Write in ." and the model picks a language at // random, which is how single pages ended up in the wrong language. func resolveSlugUpdateLanguage(ctx context.Context, updates []SlugUpdate) string { for _, u := range updates { if u.Language != "" { return u.Language } } return types.LanguageNameFromContext(ctx) } // reduceSlugUpdates returns: // - changed: whether the wiki page was created or updated // - affectedType: "ingest" or "retract" — drives downstream bookkeeping // - additionFailed: true iff the slug had entity/concept additions queued // AND source evidence validation or the editor LLM call failed, so no page exists/was // refreshed for it. Callers use this to sanitize dead [[slug]] links // elsewhere (e.g. in the doc's summary page) and to drop the slug from // the wiki log feed so users don't see a clickable entry that 404s. // - err: transport / repo error from the persisted upsert. func (s *wikiIngestService) reduceSlugUpdatesUncheckpointed( ctx context.Context, chatModel chat.Chat, kbID string, slug string, updates []SlugUpdate, tenantID uint64, batchCtx *WikiBatchContext, kidToWikiSpan map[string]*Span, ) (changed bool, affectedType string, additionFailed bool, err 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, err = s.filterLiveUpdates(ctx, kbID, updates) if err != nil { return false, "", false, wikiCheckpointError(ctx, err) } if ctx.Err() != nil { return false, "", false, wikiCheckpointError(ctx, ctx.Err()) } if len(updates) == 0 { return false, "", false, nil } // Per-slug page span attribution: a single slug can receive // contributions from multiple docs in the same batch (entity / // concept pages aggregate across sources). We attach the // postprocess.wiki.page[slug] subspan under whichever // contributing doc's wikiSpan is encountered first in the updates // list — span tree topology only allows one parent. Every // contributing knowledge id is recorded in the span's `contributors` // output so users can still see the full attribution. Pages whose // only contributors had no wikiSpan (e.g. their parse attempt // already closed and was archived) simply get a nil pageSpan, // which the tracker helpers no-op on. var ( pageSpan *Span contributors []string ) { seen := make(map[string]bool, len(updates)) for _, u := range updates { kid := u.KnowledgeID if kid == "" || seen[kid] { continue } seen[kid] = true contributors = append(contributors, kid) if pageSpan == nil { if sp, ok := kidToWikiSpan[kid]; ok && sp != nil { pageSpan = s.tracker().BeginSubSpan(ctx, sp, fmt.Sprintf("postprocess.wiki.page[%s]", slug), types.SpanKindSubSpan, types.JSONMap{ "slug": slug, "updates": len(updates), "contributors": contributors, }) } } } } var page *types.WikiPage // Deferred output captures `&page` so it observes the post-merge // state (title, page type, content snippet) at function return — // that's what's actually useful in the trace viewer, not the // stale pre-reduce shell that exists when the defer is registered. defer func() { if pageSpan == nil { return } if errors.Is(err, errWikiCheckpointYield) { s.tracker().SkipSpan(ctx, pageSpan, "checkpoint_saved_continuation_pending") return } if err != nil { s.tracker().FailSpan(ctx, pageSpan, "REDUCE_FAILED", err.Error(), err) return } if !changed { s.tracker().SkipSpan(ctx, pageSpan, "no_change") return } out := types.JSONMap{ "affected_type": affectedType, "addition_failed": additionFailed, "contributors": contributors, } if page != nil { out["page_title"] = previewText(page.Title, 160) out["page_type"] = string(page.PageType) out["page_summary"] = previewText(page.Summary, 200) out["content_preview"] = previewText(page.Content, 320) out["source_refs"] = len(page.SourceRefs) out["chunk_refs"] = len(page.ChunkRefs) out["aliases"] = []string(page.Aliases) } s.tracker().EndSpan(ctx, pageSpan, out) }() page, err = s.wikiService.GetPageBySlug(ctx, kbID, slug) if err != nil && !errors.Is(err, apprepo.ErrWikiPageNotFound) { return false, "", false, err } exists := (err == nil && page != nil) if identityErr := validateWikiPageIdentityUpdates(page, updates); identityErr != nil { return false, "", true, identityErr } baseRevision := wikiPageBaseRevision(page) writeCtx := ctx if exists { writeCtx = types.WithWikiPageWriteGuard(ctx, page) } if page != nil { copyPage := *page page = ©Page page.Aliases = append(types.StringArray(nil), page.Aliases...) page.SourceRefs = append(types.StringArray(nil), page.SourceRefs...) page.ChunkRefs = append(types.StringArray(nil), page.ChunkRefs...) } if !exists { hasAdditions := false for _, u := range updates { if u.Type == types.WikiPageTypeEntity || u.Type == types.WikiPageTypeConcept || u.Type == "summary" { hasAdditions = true break } } if !hasAdditions { return false, "", false, nil } page = &types.WikiPage{ ID: uuid.New().String(), TenantID: tenantID, KnowledgeBaseID: kbID, Slug: slug, Status: types.WikiPageStatusDraft, SourceRefs: types.StringArray{}, Aliases: types.StringArray{}, } // Reset err: GetPageBySlug returned "not found" which we just // handled by synthesizing the page. Don't leak that error to // the named return — subsequent assignments would mask it // anyway, but be explicit. err = nil } affectedType = "ingest" var summaryUpdate *SlugUpdate var retracts []SlugUpdate var additions []SlugUpdate for i, u := range updates { if u.Type == "summary" { summaryUpdate = &updates[i] } else if u.Type == "retract" || u.Type == "retractStale" { retracts = append(retracts, u) affectedType = "retract" } else if u.Type == types.WikiPageTypeEntity || u.Type == types.WikiPageTypeConcept { additions = append(additions, u) affectedType = "ingest" // Additions override retracts type } } if summaryUpdate != nil { page.Title = summaryUpdate.DocTitle + " - Summary" page.Content = summaryUpdate.SummaryBody page.Summary = summaryUpdate.SummaryLine page.PageType = types.WikiPageTypeSummary page.SourceRefs = appendUnique(page.SourceRefs, summaryUpdate.SourceRef) // Summary pages don't carry chunk-level citations (they are document- // level synopses generated from the whole content). Clear any stale // chunk refs that may remain if this slug was once an entity page // and got converted to a summary page. page.ChunkRefs = types.StringArray{} changed = true if err = s.verifyWikiSummaryEvidenceUnchanged(ctx, tenantID, kbID, *summaryUpdate, batchCtx); err != nil { return false, affectedType, true, err } if err = s.verifyWikiPageBase(ctx, kbID, slug, baseRevision); err != nil { return false, affectedType, true, err } if exists { _, err = s.wikiService.UpdatePage(writeCtx, page) } else { _, err = s.wikiService.CreatePage(ctx, page) } return changed, affectedType, false, err } // Validate all additions before retracting source refs or changing any page // metadata. A candidate without complete, scoped source evidence must not // overwrite a previously supported page or be checkpointed as published. citedChunksByID, evidenceErr := s.resolveWikiAdditionEvidence(ctx, tenantID, kbID, additions) if evidenceErr != nil { return false, affectedType, len(additions) > 0, wikiCheckpointError(ctx, evidenceErr) } chunkContentByID := make(map[string]string, len(citedChunksByID)) for id, chunk := range citedChunksByID { chunkContentByID[id] = chunk.Content } for i, add := range additions { var sources []string for _, id := range add.SourceChunks { sources = append(sources, chunkContentByID[id]) } if identityErr := validateWikiSourceItemIdentity(add.Item, add.Type, i, sources); identityErr != nil { return false, affectedType, true, identityErr } } var remainingSourcesContent strings.Builder var deletedContent strings.Builder var relatedSlugs strings.Builder var newContentBuilder strings.Builder var sharedSourceContexts strings.Builder var docTitles []string language := resolveSlugUpdateLanguage(ctx, updates) if len(retracts) > 0 { for _, r := range retracts { fmt.Fprintf(&deletedContent, "\n%s\n\n%s\n\n\n\n", r.DocTitle, r.RetractDocContent) } retractKIDs := make(map[string]bool) for _, r := range retracts { retractKIDs[r.KnowledgeID] = true } for _, ref := range page.SourceRefs { pipeIdx := strings.Index(ref, "|") var refKnowledgeID, refTitle string if pipeIdx > 0 { refKnowledgeID = ref[:pipeIdx] refTitle = ref[pipeIdx+1:] } else { refKnowledgeID = ref refTitle = ref } if retractKIDs[refKnowledgeID] { continue } if content := batchCtx.SummaryContentByKnowledgeID(ctx, refKnowledgeID); content != "" { fmt.Fprintf(&remainingSourcesContent, "\n%s\n\n%s\n\n\n\n", refTitle, content) } else { fmt.Fprintf(&remainingSourcesContent, "\n%s\n\n(summary not available)\n\n\n\n", refTitle) } } if remainingSourcesContent.Len() == 0 { remainingSourcesContent.WriteString("(no remaining sources)") } newRefs := types.StringArray{} for _, ref := range page.SourceRefs { pipeIdx := strings.Index(ref, "|") refKnowledgeID := ref if pipeIdx > 0 { refKnowledgeID = ref[:pipeIdx] } if !retractKIDs[refKnowledgeID] { newRefs = append(newRefs, ref) } } page.SourceRefs = newRefs } if len(additions) > 0 { // A document summary is shared by every page derived from that document. // Render a deterministic, de-duplicated block before any page-specific // metadata so provider prefix caches can reuse it across parallel reduce // calls. sourceContextByRef := make(map[string]string) for _, add := range additions { cited := collectCitedChunkContent(add.SourceChunks, chunkContentByID) // Frame the chunks with the document-level summary body so the // editor model knows BOTH what the document is about AND what // kind of document it is (resume vs announcement vs product // page vs schedule). The one-sentence headline alone was too // terse to keep the editor grounded on longer or multi-topic // source documents, and calibrating tone (self-reported vs // third-party authoritative) benefits from the richer context. sourceCtx := strings.TrimSpace(add.DocSummary) if sourceCtx != "" { contextKey := add.SourceRef if contextKey == "" { contextKey = add.KnowledgeID + "\x00" + add.DocTitle } sourceContextByRef[contextKey] = fmt.Sprintf( "\n%s\n\n%s\n\n\n", add.DocTitle, sourceCtx, ) } fmt.Fprintf(&newContentBuilder, "\n%s\n\n%s\n\n\n\n", add.DocTitle, cited) docTitles = appendUnique(docTitles, add.DocTitle) for _, alias := range add.Item.Aliases { page.Aliases = appendUnique(page.Aliases, alias) } page.SourceRefs = appendUnique(page.SourceRefs, add.SourceRef) if page.Title == "" { page.Title = add.Item.Name } if page.PageType == "" { page.PageType = add.Type } } contextKeys := make([]string, 0, len(sourceContextByRef)) for key := range sourceContextByRef { contextKeys = append(contextKeys, key) } sort.Strings(contextKeys) for _, key := range contextKeys { sharedSourceContexts.WriteString(sourceContextByRef[key]) } } if len(additions) > 0 || len(retracts) > 0 { titles := batchCtx.SlugTitleMany(ctx, []string(page.OutLinks)) // slugHandles escape high-entropy slugs behind short reference handles // (ref-1, ref-2, …) for the editor LLM, then translates them back to // real slugs after generation (see decodeContent below). slugHandles := newWikiSlugHandles() // Escape every out-link slug behind a request-local handle so the editor // never has to retype a real slug — this is where UUID-based summary // slugs (summary/) got mangled into 404-ing links. Both the // listing AND the [[...]] refs already present in // use the SAME handle table, so the // model sees one consistent, copy-safe identifier space; we translate // the handles back to real slugs after generation. known := make(map[string]struct{}, len(page.OutLinks)) for _, outSlug := range page.OutLinks { known[outSlug] = struct{}{} slugHandles.handle(outSlug) // pre-assign for stable ordering } for _, outSlug := range page.OutLinks { if title := titles[outSlug]; title != "" { fmt.Fprintf(&relatedSlugs, "- %s (%s)\n", slugHandles.handle(outSlug), title) } } // Older generated pages may still contain short chunk aliases such as // [c003]. They are internal ingest metadata; keep the editor context // clean so a subsequent update cannot copy them into rewritten prose. existingContent := stripWikiInlineChunkCitations(page.Content) existingContent = slugHandles.encodeContent(existingContent, known) if !exists || existingContent == "" { existingContent = "(New page)" } hasAdditionsStr := "" if len(additions) > 0 { hasAdditionsStr = "1" } hasRetractionsStr := "" if len(retracts) > 0 { hasRetractionsStr = "1" } // Fall back gracefully if title/type are still unset (shouldn't happen // for well-formed updates — both get populated from `additions` above, // and retract-only paths require an existing page — but stay defensive // so we never feed the LLM an empty identity block). pageTitle := page.Title if pageTitle == "" { pageTitle = slug } pageType := string(page.PageType) if pageType == "" { pageType = "wiki page" } pageAliases := strings.Join(page.Aliases, ", ") var updatedContent string updatedContent, err = s.generateWikiPageFromEvidence(ctx, chatModel, s.checkpointPageScope(batchCtx, updates), baseRevision, additions, citedChunksByID, map[string]string{ "HasAdditions": hasAdditionsStr, "HasRetractions": hasRetractionsStr, "PageSlug": slug, "PageTitle": pageTitle, "PageType": pageType, "PageAliases": pageAliases, "ExistingContent": existingContent, "SharedSourceContexts": sharedSourceContexts.String(), "NewContent": newContentBuilder.String(), "DeletedContent": deletedContent.String(), "RemainingSourcesContent": remainingSourcesContent.String(), "AvailableSlugs": relatedSlugs.String(), "Language": language, "CustomInstructions": batchCtx.ContentInstructions, "InstructionScope": "wiki_content", }, wikiPageStepBudget(batchCtx)) if err == nil && strings.TrimSpace(updatedContent) == "" { err = errors.New("wiki page generation returned empty content") } if err == nil && updatedContent != "" { // Translate request-local handles (ref-N) the model copied from the // encoded context back to their real slugs BEFORE the content is // parsed/stored, so out_links reflect real pages again. updatedContent = slugHandles.decodeContent(updatedContent) updatedSummary, updatedBody := splitSummaryLine(updatedContent) if updatedBody != "" { page.Content = updatedBody } else { page.Content = updatedContent } if updatedSummary != "" { page.Summary = updatedSummary } changed = true } else if err != nil { err = wikiCheckpointError(ctx, err) if errors.Is(err, errWikiCheckpointYield) { return false, affectedType, false, err } logger.Warnf(ctx, "wiki ingest: update/retract failed for slug %s: %v", slug, err) // Flag addition failures so the batch can sanitize stale // [[slug]] references in the doc's summary page and keep the // missing page out of finalize processing. // Retract-only failures don't poison anything (they leave // the existing page unchanged), so don't flag those. if len(additions) > 0 { additionFailed = true } return false, affectedType, additionFailed, err } } // Apply the batch taxonomy plan, but only to pages that aren't already // filed — so brand-new pages get a coherent folder while previously-filed // or user-moved pages keep their placement (manual edits are authoritative). // The page's category_path cache is derived from folder_id downstream by // CreatePage/UpdatePage, so assigning the folder id is sufficient here. if page.FolderID == "" && batchCtx != nil { if fid := batchCtx.PlannedFolderID[slug]; fid != "" { page.FolderID = fid } } if changed { // Refresh chunk refs in-place on the page so they persist alongside // the rest of the row. Retract-only updates (no additions) preserve // the existing refs; addition rounds append the newly-cited chunks // on top of what was already there, deduplicated. page.ChunkRefs = mergeChunkRefs(page.ChunkRefs, additions) if err = s.verifyWikiAdditionEvidenceUnchanged(ctx, tenantID, kbID, additions, citedChunksByID); err != nil { return false, affectedType, len(additions) > 0, err } if err = s.verifyWikiPageBase(ctx, kbID, slug, baseRevision); err != nil { return false, affectedType, len(additions) > 0, err } if exists { _, err = s.wikiService.UpdatePage(writeCtx, page) } else { _, err = s.wikiService.CreatePage(ctx, page) } return true, affectedType, additionFailed, err } return false, "", additionFailed, nil } // mergeChunkRefs unions the chunk IDs currently on the page with the ones // cited by this batch's additions, preserving insertion order and dropping // duplicates. Empty strings are filtered out so a malformed source_chunks // array can't leave junk in the column. // // A retract round with no additions leaves the current refs untouched — // retract-only paths don't carry chunk IDs (only knowledge IDs), and we // can't surgically filter without that info. The next time the slug is // re-materialized via additions the fresh chunks will overlay on top. func mergeChunkRefs(current types.StringArray, additions []SlugUpdate) types.StringArray { seen := make(map[string]bool, len(current)) out := make(types.StringArray, 0, len(current)) for _, id := range current { if id == "" || seen[id] { continue } seen[id] = true out = append(out, id) } for _, add := range additions { for _, chunkID := range add.SourceChunks { if chunkID == "" || seen[chunkID] { continue } seen[chunkID] = true out = append(out, chunkID) } } return out }