Coverage for src/lilbee/wiki/lazy.py: 100%
74 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Generate one wiki page on demand, from the index rather than from a build.
3A build walks every source document and spends a call on each. This path walks
4one subject: it takes that subject's chunks across every source naming it and
5writes a single page. The cost is one call for the page someone actually asked
6for, and the evidence is wider than a build's, which assigns each entity to the
7one source mentioning it most and never sees the rest.
8"""
10from __future__ import annotations
12import functools
13import logging
14from typing import TYPE_CHECKING
16from lilbee.app.services import get_services
17from lilbee.core.config import cfg
18from lilbee.core.text import clean_label_for_display
19from lilbee.runtime.progress import (
20 DetailedProgressCallback,
21 EventType,
22 WikiPageEvent,
23 WikiPhase,
24 WikiPhaseEvent,
25 noop_callback,
26)
28from .batch import hash_existing_sources
29from .citations import resolve_multi_source_citations
30from .generation import rewrite_links_across_wiki
31from .page import (
32 chunks_to_text,
33 generate_page,
34 truncate_chunks_to_budget,
35)
36from .shared import WIKI_BUILD_LOCK
37from .stubs import WikiStub, load_stub_index
39if TYPE_CHECKING:
40 import threading
41 from pathlib import Path
43 from lilbee.core.config import Config
44 from lilbee.data.store import CitationRecord, SearchChunk, Store
46 from .citations import ParsedCitation
47 from .stats import BuildStats
49log = logging.getLogger(__name__)
52class UnknownStubError(LookupError):
53 """Raised when a slug names no entry in the wiki index."""
56def _chunks_for_stub(stub: WikiStub, store: Store) -> tuple[dict[str, list[SearchChunk]], int]:
57 """The stub's chunks grouped by source, plus how many refs went unresolved.
59 Refs are looked up per source and filtered by index, so a source that was
60 re-ingested with fewer chunks contributes what it still has instead of
61 failing the page.
62 """
63 wanted: dict[str, set[int]] = {}
64 for source, index in stub.chunk_refs:
65 wanted.setdefault(source, set()).add(index)
66 if not wanted:
67 # Subtracting a re-indexed source can empty the refs while leaving real
68 # evidence: the cap may have given every ref to the source that went.
69 # The recorded sources are the truth, so fall back to reading them.
70 wanted = {source: set() for source in stub.sources}
72 # Most-mentioning source first, so when the context budget truncates it
73 # drops the documents with least to say rather than the alphabetically
74 # unlucky ones.
75 counts = dict(stub.source_mentions)
76 order = sorted(wanted, key=lambda name: (-counts.get(name, 0), name))
78 by_source: dict[str, list[SearchChunk]] = {}
79 resolved = 0
80 for source in order:
81 available = {c.chunk_index: c for c in store.get_chunks_by_source(source)}
82 indexes = wanted[source] or set(available)
83 kept = [available[i] for i in sorted(indexes) if i in available]
84 resolved += len(kept)
85 if kept:
86 by_source[source] = kept
87 return by_source, max(0, len(stub.chunk_refs) - resolved)
90def _resolve(slug: str, stubs: dict[str, WikiStub]) -> WikiStub | None:
91 """Find a stub by bare slug or by the subdir-qualified form.
93 Every surface shows pages as ``entities/ford``, so that is what a user
94 types, while the index is keyed by the bare slug. Both resolve.
95 """
96 direct = stubs.get(slug)
97 if direct is not None:
98 return direct
99 return next((stub for stub in stubs.values() if stub.wiki_slug == slug), None)
102def resolve_stub(slug: str, config: Config | None = None) -> WikiStub | None:
103 """The index entry for *slug*, by bare or subdir-qualified form."""
104 return _resolve(slug, load_stub_index(config or cfg))
107def generate_stub_page(
108 slug: str,
109 store: Store,
110 config: Config | None = None,
111 *,
112 stats: BuildStats | None = None,
113 on_progress: DetailedProgressCallback = noop_callback,
114 cancel: threading.Event | None = None,
115) -> Path | None:
116 """Write the page for one indexed subject. Returns its path, or None.
118 Runs the same citation verification, faithfulness gate, and drafts
119 quarantine a build does; nothing here bypasses them. Holds the wiki mutex,
120 so a page generated from the browse tree cannot interleave with a build.
121 Emits the same wiki_phase/wiki_page events a build does; a *cancel* set
122 before the model call skips it.
123 """
124 if config is None:
125 config = cfg
126 with WIKI_BUILD_LOCK:
127 stubs = load_stub_index(config)
128 stub = _resolve(slug, stubs)
129 if stub is None:
130 raise UnknownStubError(f"no indexed page named {slug!r}")
132 chunks_by_source, unresolved = _chunks_for_stub(stub, store)
133 if not chunks_by_source:
134 log.warning("No chunks remain for %s; the index is stale", slug)
135 return None
136 if unresolved:
137 log.info("%d of %s's indexed chunks are gone", unresolved, slug)
139 on_progress(EventType.WIKI_PHASE, WikiPhaseEvent(phase=WikiPhase.GENERATE, total=1))
140 if cancel is not None and cancel.is_set():
141 log.info("Generation of %s cancelled before the model call", slug)
142 return None
144 source_names = sorted(chunks_by_source)
145 all_chunks = [c for chunks in chunks_by_source.values() for c in chunks]
146 source_list = "\n".join(f"- {name}" for name in source_names)
147 display = clean_label_for_display(stub.label)
148 render = functools.partial(
149 config.wiki_entity_page_prompt.format, topic=display, source_list=source_list
150 )
151 all_chunks = truncate_chunks_to_budget(all_chunks, config, len(render(chunks_text="")))
152 prompt = render(chunks_text=chunks_to_text(all_chunks))
153 source_hashes = hash_existing_sources(source_names)
155 def resolver(parsed: list[ParsedCitation]) -> list[CitationRecord]:
156 return resolve_multi_source_citations(
157 parsed, source_names, source_hashes, chunks_by_source
158 )
160 path = generate_page(
161 label=stub.label,
162 prompt=prompt,
163 chunks=all_chunks,
164 citation_resolver=resolver,
165 page_type=stub.subdir,
166 slug=stub.slug,
167 source_names=source_names,
168 provider=get_services().provider,
169 store=store,
170 config=config,
171 stats=stats,
172 # These documents mention the subject; the page does not replace
173 # them. Pruning here would delete every document that named it.
174 supersedes_sources=False,
175 )
176 if path is not None:
177 # The link pass a build runs; without it the page has no [[links]]
178 # and sits alone in the graph. No entities: the surface map is then
179 # built from the pages on disk, this one included, so links go both
180 # ways.
181 rewrite_links_across_wiki([], config)
182 on_progress(
183 EventType.WIKI_PAGE,
184 WikiPageEvent(label=stub.label, pages=1, current=1, total=1),
185 )
186 return path