Coverage for src/lilbee/wiki/ingest.py: 100%
39 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""Wiki post-ingest hook: regenerate pages touched by a recent sync."""
3from __future__ import annotations
5import asyncio
6import logging
8from lilbee.app.services import get_services
9from lilbee.core.config import Config, cfg
11log = logging.getLogger(__name__)
14async def incremental_update(changed_sources: set[str], config: Config | None = None) -> None:
15 """Regenerate only the wiki pages touched by *changed_sources*.
17 Builds a fresh ``ExtractedEntity`` set from the current corpus and
18 keeps the records whose chunk trail includes one of the changed
19 sources. An entity with no page on disk is not by itself a reason to
20 regenerate: its page may be a draft or a marker held for review, and
21 re-queueing those every sync burns LLM calls and overwrites pending
22 review content. Above ``cfg.wiki_ingest_update_cap`` touched pages
23 the auto-update bails out and logs a manual-update hint instead.
24 The caller is a sync and holds the sync mark, so no source key moves
25 during the update.
26 """
27 if config is None:
28 config = cfg
29 if not config.wiki or not changed_sources:
30 return
31 from lilbee.data.store import SearchChunk
32 from lilbee.wiki import append_wiki_log, build_wiki, update_wiki_index
33 from lilbee.wiki.entity_extractor import get_entity_extractor
34 from lilbee.wiki.shared import WIKI_BUILD_LOCK, WikiLogAction
35 from lilbee.wiki.stats import BuildStats
37 svc = get_services()
38 extractor = get_entity_extractor(config.wiki_entity_mode, svc.provider, config)
40 chunks: list[SearchChunk] = []
41 for record in svc.store.get_sources():
42 chunks.extend(svc.store.get_chunks_by_source(record["filename"]))
43 entities = await asyncio.to_thread(extractor.extract, chunks)
45 touched = [
46 entity
47 for entity in entities
48 if any(ref.source in changed_sources for ref in entity.chunk_refs)
49 ]
51 if not touched:
52 return
54 if len(touched) > config.wiki_ingest_update_cap:
55 # warning, not info: the default LILBEE_LOG_LEVEL is WARNING, so
56 # log.info would silently drop the manual-update hint and the user
57 # would see no signal at all during `lilbee sync` when the cap trips.
58 log.warning(
59 "Wiki auto-update skipped: %d pages touched (cap %d). "
60 "Run 'lilbee wiki update' for a full rebuild.",
61 len(touched),
62 config.wiki_ingest_update_cap,
63 )
65 def _log_skip() -> None:
66 with WIKI_BUILD_LOCK:
67 append_wiki_log(
68 WikiLogAction.INGEST,
69 f"skipped: {len(touched)} pages exceeds cap {config.wiki_ingest_update_cap}",
70 config,
71 )
73 # log.md is shared with the builders, so the append takes the mutex in a
74 # worker thread for the same reason the regenerate path does.
75 await asyncio.to_thread(_log_skip)
76 return
78 # extract_concepts=False so an incremental sync does not churn
79 # concept slugs. Concept curation is a deliberate, user-invoked
80 # refresh (full `lilbee wiki build`).
81 def _regenerate() -> None:
82 stats = BuildStats()
83 with WIKI_BUILD_LOCK:
84 pages = build_wiki(
85 touched, svc.provider, svc.store, config, extract_concepts=False, stats=stats
86 )
87 update_wiki_index(config)
88 append_wiki_log(
89 WikiLogAction.INGEST,
90 f"{len(pages)} pages regenerated for {', '.join(sorted(changed_sources))}; "
91 f"{stats.summary_line()}",
92 config,
93 )
95 # The mutex is taken in the worker thread: acquiring it here would block the
96 # event loop for the length of another surface's build.
97 await asyncio.to_thread(_regenerate)