okf-core 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- okf_core/__init__.py +110 -0
- okf_core/_version.py +1 -0
- okf_core/ask.py +512 -0
- okf_core/bundle.py +83 -0
- okf_core/cache.py +79 -0
- okf_core/canonical.py +176 -0
- okf_core/explorer.py +386 -0
- okf_core/findings.py +78 -0
- okf_core/frontmatter.py +196 -0
- okf_core/indexing.py +325 -0
- okf_core/links.py +62 -0
- okf_core/lint.py +536 -0
- okf_core/packer.py +130 -0
- okf_core/pipeline.py +566 -0
- okf_core/provider.py +80 -0
- okf_core/py.typed +0 -0
- okf_core/rag.py +179 -0
- okf_core/revision.py +137 -0
- okf_core/viz.py +310 -0
- okf_core-0.3.0.dist-info/METADATA +18 -0
- okf_core-0.3.0.dist-info/RECORD +22 -0
- okf_core-0.3.0.dist-info/WHEEL +4 -0
okf_core/__init__.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""okf-core: CanonicalDoc, OKF v0.2 parser/writer, five-level lint, pipeline,
|
|
2
|
+
revisions, packer.
|
|
3
|
+
|
|
4
|
+
The heart of OpenKnoll — the CLI and the cloud pipeline job run this identical
|
|
5
|
+
code.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from okf_core._version import __version__
|
|
9
|
+
from okf_core.ask import AskResult, Citation, answer_question, write_trace
|
|
10
|
+
from okf_core.cache import BuildCache
|
|
11
|
+
from okf_core.canonical import (
|
|
12
|
+
Anchor,
|
|
13
|
+
Block,
|
|
14
|
+
CanonicalDoc,
|
|
15
|
+
Link,
|
|
16
|
+
MediaRef,
|
|
17
|
+
SourceRef,
|
|
18
|
+
sha256_hex,
|
|
19
|
+
)
|
|
20
|
+
from okf_core.explorer import Explorer, ExplorerError
|
|
21
|
+
from okf_core.findings import Finding, Level, LintReport, Severity
|
|
22
|
+
from okf_core.frontmatter import (
|
|
23
|
+
KNOWN_KEY_ORDER,
|
|
24
|
+
STATUS_VALUES,
|
|
25
|
+
Frontmatter,
|
|
26
|
+
FrontmatterError,
|
|
27
|
+
FrontmatterShapeError,
|
|
28
|
+
FrontmatterYamlError,
|
|
29
|
+
ParsedDocument,
|
|
30
|
+
parse_document,
|
|
31
|
+
write_document,
|
|
32
|
+
)
|
|
33
|
+
from okf_core.indexing import build_link_graph, ensure_index, search_index, write_index
|
|
34
|
+
from okf_core.lint import LintConfig, lint_bundle
|
|
35
|
+
from okf_core.packer import PackResult, pack_bundle, strip_okf_fields
|
|
36
|
+
from okf_core.pipeline import (
|
|
37
|
+
BuildOutcome,
|
|
38
|
+
PipelineError,
|
|
39
|
+
PipelineSource,
|
|
40
|
+
build_revision,
|
|
41
|
+
check_reproducibility,
|
|
42
|
+
)
|
|
43
|
+
from okf_core.provider import (
|
|
44
|
+
GENERATOR_VERSION,
|
|
45
|
+
ModelProvider,
|
|
46
|
+
StubModelProvider,
|
|
47
|
+
generation_cache_key,
|
|
48
|
+
resolve_provider,
|
|
49
|
+
)
|
|
50
|
+
from okf_core.rag import EmbeddingProvider, StubEmbeddingProvider, resolve_embedder
|
|
51
|
+
from okf_core.revision import read_current_revision_id, revision_dir
|
|
52
|
+
from okf_core.viz import build_graph, render_html, write_viz
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"GENERATOR_VERSION",
|
|
56
|
+
"KNOWN_KEY_ORDER",
|
|
57
|
+
"STATUS_VALUES",
|
|
58
|
+
"Anchor",
|
|
59
|
+
"AskResult",
|
|
60
|
+
"Block",
|
|
61
|
+
"BuildCache",
|
|
62
|
+
"BuildOutcome",
|
|
63
|
+
"CanonicalDoc",
|
|
64
|
+
"Citation",
|
|
65
|
+
"EmbeddingProvider",
|
|
66
|
+
"Explorer",
|
|
67
|
+
"ExplorerError",
|
|
68
|
+
"Finding",
|
|
69
|
+
"Frontmatter",
|
|
70
|
+
"FrontmatterError",
|
|
71
|
+
"FrontmatterShapeError",
|
|
72
|
+
"FrontmatterYamlError",
|
|
73
|
+
"Level",
|
|
74
|
+
"Link",
|
|
75
|
+
"LintConfig",
|
|
76
|
+
"LintReport",
|
|
77
|
+
"MediaRef",
|
|
78
|
+
"ModelProvider",
|
|
79
|
+
"PackResult",
|
|
80
|
+
"ParsedDocument",
|
|
81
|
+
"PipelineError",
|
|
82
|
+
"PipelineSource",
|
|
83
|
+
"Severity",
|
|
84
|
+
"SourceRef",
|
|
85
|
+
"StubEmbeddingProvider",
|
|
86
|
+
"StubModelProvider",
|
|
87
|
+
"__version__",
|
|
88
|
+
"answer_question",
|
|
89
|
+
"build_graph",
|
|
90
|
+
"build_link_graph",
|
|
91
|
+
"build_revision",
|
|
92
|
+
"check_reproducibility",
|
|
93
|
+
"ensure_index",
|
|
94
|
+
"generation_cache_key",
|
|
95
|
+
"lint_bundle",
|
|
96
|
+
"pack_bundle",
|
|
97
|
+
"parse_document",
|
|
98
|
+
"read_current_revision_id",
|
|
99
|
+
"render_html",
|
|
100
|
+
"resolve_embedder",
|
|
101
|
+
"resolve_provider",
|
|
102
|
+
"revision_dir",
|
|
103
|
+
"search_index",
|
|
104
|
+
"sha256_hex",
|
|
105
|
+
"strip_okf_fields",
|
|
106
|
+
"write_document",
|
|
107
|
+
"write_index",
|
|
108
|
+
"write_trace",
|
|
109
|
+
"write_viz",
|
|
110
|
+
]
|
okf_core/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
okf_core/ask.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""One-shot explorer answers: navigation policy, answer contract, trace.
|
|
2
|
+
|
|
3
|
+
Navigation policy is deterministic code, not model output: start at
|
|
4
|
+
``overview``, narrow with lexical ``search``, ``peek`` before ``read``, follow
|
|
5
|
+
``links`` with bounded fan-out (≤4), stop when the answer is supported or the
|
|
6
|
+
token budget (default 25,000 input tokens) is spent. The model only writes the
|
|
7
|
+
final prose over retrieved evidence — with the deterministic stub the whole
|
|
8
|
+
answer is a pure function of the bundle and the question.
|
|
9
|
+
|
|
10
|
+
Answer contract: citations carry qualified concept paths plus OKF source
|
|
11
|
+
ids and resource links; warnings flag draft/deprecated/stale/unverified
|
|
12
|
+
evidence; on insufficient evidence the answer abstains and names what would
|
|
13
|
+
resolve the gap. Bundle text is untrusted data — it is quoted, never obeyed.
|
|
14
|
+
|
|
15
|
+
The trace is a separate record (tools, paths, retrieved characters, token
|
|
16
|
+
estimates, latency, model, revision id, retrieval condition), derived state
|
|
17
|
+
stored under ``.oknoll/traces/`` — never part of a portable bundle.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
import time
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from datetime import UTC, datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from okf_core import bundle as bundle_mod
|
|
32
|
+
from okf_core.canonical import sha256_hex
|
|
33
|
+
from okf_core.explorer import Explorer, estimate_tokens
|
|
34
|
+
from okf_core.frontmatter import Frontmatter, parse_document
|
|
35
|
+
from okf_core.indexing import question_terms, term_occurrences, tokenize
|
|
36
|
+
from okf_core.provider import ModelProvider
|
|
37
|
+
from okf_core.rag import (
|
|
38
|
+
CHUNK_CHARS,
|
|
39
|
+
EmbeddingProvider,
|
|
40
|
+
ensure_rag_index,
|
|
41
|
+
resolve_embedder,
|
|
42
|
+
retrieve,
|
|
43
|
+
)
|
|
44
|
+
from okf_core.revision import read_current_revision_id
|
|
45
|
+
|
|
46
|
+
DEFAULT_TOKEN_BUDGET = 25_000
|
|
47
|
+
MAX_LINK_FANOUT = 4
|
|
48
|
+
MAX_CONCEPT_READS = 4
|
|
49
|
+
MAX_EVIDENCE = 4
|
|
50
|
+
EXCERPT_CHARS = 500
|
|
51
|
+
TRACES_DIR = f"{bundle_mod.DERIVED_STATE_DIR}/traces"
|
|
52
|
+
TRACE_SCHEMA_VERSION = 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class Citation:
|
|
57
|
+
path: str # qualified bundle path
|
|
58
|
+
title: str
|
|
59
|
+
source_ids: list[str]
|
|
60
|
+
resources: list[str] # reference snapshot paths or original resource URIs
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict[str, Any]:
|
|
63
|
+
return {
|
|
64
|
+
"path": self.path,
|
|
65
|
+
"title": self.title,
|
|
66
|
+
"source_ids": self.source_ids,
|
|
67
|
+
"resources": self.resources,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(slots=True)
|
|
72
|
+
class AskResult:
|
|
73
|
+
question: str
|
|
74
|
+
answer: str
|
|
75
|
+
abstained: bool
|
|
76
|
+
citations: list[Citation]
|
|
77
|
+
warnings: list[str]
|
|
78
|
+
trace: dict[str, Any]
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> dict[str, Any]:
|
|
81
|
+
return {
|
|
82
|
+
"question": self.question,
|
|
83
|
+
"answer": self.answer,
|
|
84
|
+
"abstained": self.abstained,
|
|
85
|
+
"citations": [c.to_dict() for c in self.citations],
|
|
86
|
+
"warnings": self.warnings,
|
|
87
|
+
"trace": self.trace,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(slots=True)
|
|
92
|
+
class _Traced:
|
|
93
|
+
"""Wraps the explorer so every tool call lands in the trace with its cost."""
|
|
94
|
+
|
|
95
|
+
explorer: Explorer
|
|
96
|
+
events: list[dict[str, Any]] = field(default_factory=list)
|
|
97
|
+
spent_chars: int = 0
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def spent_tokens(self) -> int:
|
|
101
|
+
return estimate_tokens(self.spent_chars)
|
|
102
|
+
|
|
103
|
+
def call(self, tool: str, /, **kwargs: Any) -> dict[str, Any]:
|
|
104
|
+
method: Callable[..., dict[str, Any]] = getattr(self.explorer, tool)
|
|
105
|
+
result = method(**kwargs)
|
|
106
|
+
chars = len(json.dumps(result, ensure_ascii=False, default=str))
|
|
107
|
+
self.spent_chars += chars
|
|
108
|
+
self.events.append(
|
|
109
|
+
{"tool": tool, "args": kwargs, "chars": chars, "tokens": estimate_tokens(chars)}
|
|
110
|
+
)
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_FOOTNOTE_MARKER_RE = re.compile(r"\[\^[^\]]+\]")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _paragraphs(body: str) -> list[str]:
|
|
118
|
+
return [p.strip() for p in body.split("\n\n") if p.strip()]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _best_excerpt(body: str, terms: set[str]) -> tuple[str, int]:
|
|
122
|
+
"""The paragraph with the most distinct question-term hits.
|
|
123
|
+
|
|
124
|
+
Ties break on total term occurrences, then earliest. A single-salient-term
|
|
125
|
+
question ("What is A2K?") gives every mentioning paragraph the same distinct
|
|
126
|
+
count; occurrence density is what separates the section that answers it from
|
|
127
|
+
a passing mention, and it is just as deterministic.
|
|
128
|
+
"""
|
|
129
|
+
best, best_key = "", (0, 0)
|
|
130
|
+
for para in _paragraphs(body):
|
|
131
|
+
distinct = len(terms & tokenize(para))
|
|
132
|
+
if distinct == 0:
|
|
133
|
+
continue
|
|
134
|
+
key = (distinct, term_occurrences(para, terms))
|
|
135
|
+
if key > best_key:
|
|
136
|
+
best, best_key = para, key
|
|
137
|
+
excerpt = _FOOTNOTE_MARKER_RE.sub("", best).strip() # markers are for the file, not prose
|
|
138
|
+
return excerpt[:EXCERPT_CHARS], best_key[0]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _concept_warnings(path: str, frontmatter: dict[str, Any], today: str | None) -> list[str]:
|
|
142
|
+
"""Trust warnings for one cited concept, phrased next to the affected claim."""
|
|
143
|
+
fm = Frontmatter(data=frontmatter)
|
|
144
|
+
warnings: list[str] = []
|
|
145
|
+
if fm.status == "draft":
|
|
146
|
+
warnings.append(f"{path}: status is draft — content is unreviewed")
|
|
147
|
+
elif fm.status == "deprecated":
|
|
148
|
+
warnings.append(f"{path}: status is deprecated — may no longer apply")
|
|
149
|
+
if today and fm.stale_after and fm.stale_after < today:
|
|
150
|
+
warnings.append(f"{path}: stale since {fm.stale_after}")
|
|
151
|
+
if not fm.verified:
|
|
152
|
+
warnings.append(f"{path}: unverified — no verification record")
|
|
153
|
+
return warnings
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _citation_for(path: str, frontmatter: dict[str, Any], title: str) -> Citation:
|
|
157
|
+
fm = Frontmatter(data=frontmatter)
|
|
158
|
+
resources: list[str] = []
|
|
159
|
+
for source in fm.sources:
|
|
160
|
+
resource = source.get("resource")
|
|
161
|
+
if isinstance(resource, str):
|
|
162
|
+
resources.append(resource)
|
|
163
|
+
origin = frontmatter.get("openknoll_source")
|
|
164
|
+
if isinstance(origin, dict) and isinstance(origin.get("uri"), str):
|
|
165
|
+
resources.append(origin["uri"]) # reference snapshots cite their origin
|
|
166
|
+
return Citation(path=path, title=title, source_ids=fm.source_ids(), resources=resources)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def answer_question(
|
|
170
|
+
*,
|
|
171
|
+
bundle_dir: Path,
|
|
172
|
+
question: str,
|
|
173
|
+
provider: ModelProvider,
|
|
174
|
+
today: str | None = None,
|
|
175
|
+
budget_tokens: int = DEFAULT_TOKEN_BUDGET,
|
|
176
|
+
condition: str = "pd",
|
|
177
|
+
embedder: EmbeddingProvider | None = None,
|
|
178
|
+
clock: Callable[[], str] | None = None,
|
|
179
|
+
timer: Callable[[], float] | None = None,
|
|
180
|
+
) -> AskResult:
|
|
181
|
+
"""Answer one question under a retrieval condition.
|
|
182
|
+
|
|
183
|
+
``pd`` runs the deterministic navigation policy over concepts; ``rag`` runs
|
|
184
|
+
the vector top-k baseline over the identical normalized corpus (reference
|
|
185
|
+
snapshots). Both share the answer contract, the evidence budget
|
|
186
|
+
(≤ MAX_EVIDENCE excerpts of ≤ EXCERPT_CHARS), and the trace shape.
|
|
187
|
+
"""
|
|
188
|
+
if condition not in ("pd", "rag"):
|
|
189
|
+
raise ValueError(f"unknown retrieval condition {condition!r} — available: 'pd', 'rag'")
|
|
190
|
+
clock = clock or (lambda: datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ"))
|
|
191
|
+
timer = timer or time.monotonic
|
|
192
|
+
if condition == "rag":
|
|
193
|
+
return _answer_rag(
|
|
194
|
+
bundle_dir=bundle_dir,
|
|
195
|
+
question=question,
|
|
196
|
+
provider=provider,
|
|
197
|
+
embedder=embedder if embedder is not None else resolve_embedder("stub"),
|
|
198
|
+
budget_tokens=budget_tokens,
|
|
199
|
+
clock=clock,
|
|
200
|
+
timer=timer,
|
|
201
|
+
)
|
|
202
|
+
started_at = clock()
|
|
203
|
+
t0 = timer()
|
|
204
|
+
|
|
205
|
+
explorer = Explorer(bundle_dir, today=today)
|
|
206
|
+
traced = _Traced(explorer)
|
|
207
|
+
terms = set(question_terms(question))
|
|
208
|
+
|
|
209
|
+
# 1. overview is always the first call (navigation-policy guardrail).
|
|
210
|
+
traced.call("overview")
|
|
211
|
+
|
|
212
|
+
# 2. narrow through lexical search. Concepts are the navigation surface;
|
|
213
|
+
# reference snapshots are raw sources, consulted only when no concept hit.
|
|
214
|
+
search = traced.call("search", query=question)
|
|
215
|
+
results = list(search["results"])
|
|
216
|
+
concept_hits = [str(r["path"]) for r in results if r["kind"] == "concept"]
|
|
217
|
+
reference_hits = [str(r["path"]) for r in results if r["kind"] == "reference"]
|
|
218
|
+
candidates = concept_hits[:MAX_CONCEPT_READS]
|
|
219
|
+
if not candidates:
|
|
220
|
+
candidates = reference_hits[:MAX_CONCEPT_READS]
|
|
221
|
+
|
|
222
|
+
# 3. peek before read; both stay within the token budget — peeks over large
|
|
223
|
+
# bundles are not free, and the budget is a hard cap, not a suggestion.
|
|
224
|
+
read_docs: list[dict[str, Any]] = []
|
|
225
|
+
budget_exhausted = False
|
|
226
|
+
for path in candidates:
|
|
227
|
+
if traced.spent_tokens >= budget_tokens:
|
|
228
|
+
budget_exhausted = True
|
|
229
|
+
break
|
|
230
|
+
traced.call("peek", path=path)
|
|
231
|
+
for path in candidates:
|
|
232
|
+
if traced.spent_tokens >= budget_tokens:
|
|
233
|
+
budget_exhausted = True
|
|
234
|
+
break
|
|
235
|
+
doc = traced.call("read", path=path)
|
|
236
|
+
doc["_terms"] = terms
|
|
237
|
+
doc["_via"] = None
|
|
238
|
+
read_docs.append(doc)
|
|
239
|
+
|
|
240
|
+
# 4. follow concept→concept links with bounded fan-out (≤4). A hop is
|
|
241
|
+
# justified by its anchor text, so the linked document is scored against
|
|
242
|
+
# the question terms *plus* that anchor's terms.
|
|
243
|
+
followed = 0
|
|
244
|
+
seen_paths = {str(doc["path"]) for doc in read_docs}
|
|
245
|
+
for doc in list(read_docs):
|
|
246
|
+
if followed >= MAX_LINK_FANOUT or traced.spent_tokens >= budget_tokens:
|
|
247
|
+
break
|
|
248
|
+
parent = str(doc["path"])
|
|
249
|
+
if not bundle_mod.is_concept(parent):
|
|
250
|
+
continue
|
|
251
|
+
anchors = {
|
|
252
|
+
str(link["resolved"]): str(link["text"])
|
|
253
|
+
for link in doc["links"]
|
|
254
|
+
if isinstance(link, dict) and link.get("resolved")
|
|
255
|
+
}
|
|
256
|
+
edges = traced.call("links", path=parent)
|
|
257
|
+
for target in list(edges["outbound"]):
|
|
258
|
+
if followed >= MAX_LINK_FANOUT:
|
|
259
|
+
break
|
|
260
|
+
if traced.spent_tokens >= budget_tokens:
|
|
261
|
+
budget_exhausted = True
|
|
262
|
+
break
|
|
263
|
+
if target in seen_paths or not bundle_mod.is_concept(target):
|
|
264
|
+
continue
|
|
265
|
+
seen_paths.add(target)
|
|
266
|
+
linked = traced.call("read", path=target)
|
|
267
|
+
linked["_terms"] = terms | tokenize(anchors.get(target, ""))
|
|
268
|
+
linked["_via"] = parent
|
|
269
|
+
read_docs.append(linked)
|
|
270
|
+
followed += 1
|
|
271
|
+
|
|
272
|
+
# 5. deterministic evidence selection: best matching paragraph per document,
|
|
273
|
+
# with reference snapshots of an already-cited concept folded away.
|
|
274
|
+
evidence: list[dict[str, Any]] = []
|
|
275
|
+
for doc in read_docs:
|
|
276
|
+
doc_terms = doc["_terms"] if isinstance(doc["_terms"], set) else terms
|
|
277
|
+
excerpt, score = _best_excerpt(str(doc["body"]), doc_terms)
|
|
278
|
+
if score < 1:
|
|
279
|
+
continue
|
|
280
|
+
frontmatter = doc["frontmatter"] if isinstance(doc["frontmatter"], dict) else {}
|
|
281
|
+
title = frontmatter.get("title") or str(doc["path"])
|
|
282
|
+
evidence.append(
|
|
283
|
+
{
|
|
284
|
+
"path": str(doc["path"]),
|
|
285
|
+
"title": str(title),
|
|
286
|
+
"excerpt": excerpt,
|
|
287
|
+
"score": score,
|
|
288
|
+
"frontmatter": frontmatter,
|
|
289
|
+
"via": doc["_via"],
|
|
290
|
+
}
|
|
291
|
+
)
|
|
292
|
+
evidence.sort(key=lambda e: (-int(e["score"]), str(e["path"])))
|
|
293
|
+
cited_resources: set[str] = set()
|
|
294
|
+
for entry in evidence:
|
|
295
|
+
for source in Frontmatter(data=entry["frontmatter"]).sources:
|
|
296
|
+
resource = source.get("resource")
|
|
297
|
+
if isinstance(resource, str):
|
|
298
|
+
cited_resources.add(resource)
|
|
299
|
+
evidence = [e for e in evidence if e["path"] not in cited_resources][:MAX_EVIDENCE]
|
|
300
|
+
|
|
301
|
+
citations: list[Citation] = []
|
|
302
|
+
warnings: list[str] = []
|
|
303
|
+
if not evidence:
|
|
304
|
+
abstained = True
|
|
305
|
+
wanted = ", ".join(sorted(terms)) or "the question topic"
|
|
306
|
+
if candidates:
|
|
307
|
+
answer = (
|
|
308
|
+
"Insufficient evidence in this bundle to answer. "
|
|
309
|
+
f"The closest match is {candidates[0]}, but it does not address: {wanted}. "
|
|
310
|
+
f"A concept covering {wanted} would resolve the gap."
|
|
311
|
+
)
|
|
312
|
+
else:
|
|
313
|
+
answer = (
|
|
314
|
+
"Insufficient evidence in this bundle to answer. "
|
|
315
|
+
f"No concept mentions: {wanted}. "
|
|
316
|
+
f"Adding a source about {wanted} would resolve the gap."
|
|
317
|
+
)
|
|
318
|
+
else:
|
|
319
|
+
abstained = False
|
|
320
|
+
answer = provider.complete(
|
|
321
|
+
"answer-question",
|
|
322
|
+
{
|
|
323
|
+
"question": question,
|
|
324
|
+
"evidence": [
|
|
325
|
+
{"path": e["path"], "title": e["title"], "excerpt": e["excerpt"]}
|
|
326
|
+
for e in evidence
|
|
327
|
+
],
|
|
328
|
+
},
|
|
329
|
+
)
|
|
330
|
+
for entry in evidence:
|
|
331
|
+
frontmatter = entry["frontmatter"]
|
|
332
|
+
citations.append(_citation_for(entry["path"], frontmatter, entry["title"]))
|
|
333
|
+
if bundle_mod.is_concept(entry["path"]):
|
|
334
|
+
warnings.extend(_concept_warnings(entry["path"], frontmatter, today))
|
|
335
|
+
|
|
336
|
+
latency_ms = int((timer() - t0) * 1000)
|
|
337
|
+
trace: dict[str, Any] = {
|
|
338
|
+
"schema_version": TRACE_SCHEMA_VERSION,
|
|
339
|
+
"question": question,
|
|
340
|
+
"condition": condition,
|
|
341
|
+
"model": provider.id,
|
|
342
|
+
"revision_id": read_current_revision_id(explorer.root),
|
|
343
|
+
"started_at": started_at,
|
|
344
|
+
"latency_ms": latency_ms,
|
|
345
|
+
"budget": {
|
|
346
|
+
"limit_tokens": budget_tokens,
|
|
347
|
+
"spent_tokens": traced.spent_tokens,
|
|
348
|
+
"spent_chars": traced.spent_chars,
|
|
349
|
+
"exhausted": budget_exhausted,
|
|
350
|
+
},
|
|
351
|
+
"policy": {
|
|
352
|
+
"max_link_fanout": MAX_LINK_FANOUT,
|
|
353
|
+
"max_concept_reads": MAX_CONCEPT_READS,
|
|
354
|
+
},
|
|
355
|
+
"tools": traced.events,
|
|
356
|
+
"paths_read": sorted({str(doc["path"]) for doc in read_docs}),
|
|
357
|
+
"hops": [
|
|
358
|
+
{"path": str(doc["path"]), "via": doc["_via"]}
|
|
359
|
+
for doc in read_docs
|
|
360
|
+
if doc["_via"] is not None
|
|
361
|
+
],
|
|
362
|
+
"evidence_paths": [str(e["path"]) for e in evidence],
|
|
363
|
+
"abstained": abstained,
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return AskResult(
|
|
367
|
+
question=question,
|
|
368
|
+
answer=answer,
|
|
369
|
+
abstained=abstained,
|
|
370
|
+
citations=citations,
|
|
371
|
+
warnings=warnings,
|
|
372
|
+
trace=trace,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _answer_rag(
|
|
377
|
+
*,
|
|
378
|
+
bundle_dir: Path,
|
|
379
|
+
question: str,
|
|
380
|
+
provider: ModelProvider,
|
|
381
|
+
embedder: EmbeddingProvider,
|
|
382
|
+
budget_tokens: int,
|
|
383
|
+
clock: Callable[[], str],
|
|
384
|
+
timer: Callable[[], float],
|
|
385
|
+
) -> AskResult:
|
|
386
|
+
"""The vector top-k baseline.
|
|
387
|
+
|
|
388
|
+
Retrieval is embedding-only over reference-snapshot chunks — no index-first
|
|
389
|
+
navigation, no link following, and no concept trust metadata, which is
|
|
390
|
+
exactly what the PD-vs-RAG comparison measures (RQ3: references carry no
|
|
391
|
+
status/verification fields, so the warnings channel is structurally empty).
|
|
392
|
+
"""
|
|
393
|
+
started_at = clock()
|
|
394
|
+
t0 = timer()
|
|
395
|
+
|
|
396
|
+
index = ensure_rag_index(bundle_dir, embedder)
|
|
397
|
+
[query_vector] = embedder.embed([question])
|
|
398
|
+
hits = retrieve(index, query_vector, MAX_EVIDENCE)
|
|
399
|
+
|
|
400
|
+
# Equal budgets with PD: evidence is capped per-passage by construction
|
|
401
|
+
# (CHUNK_CHARS == EXCERPT_CHARS) and total retrieved chars are budgeted.
|
|
402
|
+
events: list[dict[str, Any]] = []
|
|
403
|
+
spent_chars = len(question)
|
|
404
|
+
selected: list[dict[str, Any]] = []
|
|
405
|
+
budget_exhausted = False
|
|
406
|
+
for hit in hits:
|
|
407
|
+
if estimate_tokens(spent_chars + len(hit["text"])) > budget_tokens:
|
|
408
|
+
budget_exhausted = True
|
|
409
|
+
break
|
|
410
|
+
spent_chars += len(hit["text"])
|
|
411
|
+
selected.append(hit)
|
|
412
|
+
events.append(
|
|
413
|
+
{
|
|
414
|
+
"tool": "vector_search",
|
|
415
|
+
"args": {"k": MAX_EVIDENCE, "embedder": embedder.id, "chunks": len(index["chunks"])},
|
|
416
|
+
"chars": spent_chars,
|
|
417
|
+
"tokens": estimate_tokens(spent_chars),
|
|
418
|
+
}
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
frontmatters: dict[str, dict[str, Any]] = {}
|
|
422
|
+
evidence: list[dict[str, Any]] = []
|
|
423
|
+
for hit in selected:
|
|
424
|
+
path = str(hit["path"])
|
|
425
|
+
if path not in frontmatters:
|
|
426
|
+
parsed = parse_document((bundle_dir / path).read_text(encoding="utf-8"))
|
|
427
|
+
frontmatters[path] = parsed.frontmatter.data if parsed.frontmatter else {}
|
|
428
|
+
title = frontmatters[path].get("title") or path
|
|
429
|
+
evidence.append({"path": path, "title": str(title), "excerpt": str(hit["text"])})
|
|
430
|
+
|
|
431
|
+
citations: list[Citation] = []
|
|
432
|
+
if not evidence:
|
|
433
|
+
abstained = True
|
|
434
|
+
wanted = ", ".join(sorted(set(question_terms(question)))) or "the question topic"
|
|
435
|
+
answer = (
|
|
436
|
+
"Insufficient evidence in this bundle to answer. "
|
|
437
|
+
f"No indexed passage matches: {wanted}. "
|
|
438
|
+
f"Adding a source about {wanted} would resolve the gap."
|
|
439
|
+
)
|
|
440
|
+
else:
|
|
441
|
+
abstained = False
|
|
442
|
+
answer = provider.complete(
|
|
443
|
+
"answer-question",
|
|
444
|
+
{
|
|
445
|
+
"question": question,
|
|
446
|
+
"evidence": [
|
|
447
|
+
{"path": e["path"], "title": e["title"], "excerpt": e["excerpt"]}
|
|
448
|
+
for e in evidence
|
|
449
|
+
],
|
|
450
|
+
},
|
|
451
|
+
)
|
|
452
|
+
for path in dict.fromkeys(str(e["path"]) for e in evidence):
|
|
453
|
+
frontmatter = frontmatters[path]
|
|
454
|
+
title = str(frontmatter.get("title") or path)
|
|
455
|
+
citations.append(_citation_for(path, frontmatter, title))
|
|
456
|
+
|
|
457
|
+
latency_ms = int((timer() - t0) * 1000)
|
|
458
|
+
trace: dict[str, Any] = {
|
|
459
|
+
"schema_version": TRACE_SCHEMA_VERSION,
|
|
460
|
+
"question": question,
|
|
461
|
+
"condition": "rag",
|
|
462
|
+
"model": provider.id,
|
|
463
|
+
"revision_id": index["revision_id"],
|
|
464
|
+
"started_at": started_at,
|
|
465
|
+
"latency_ms": latency_ms,
|
|
466
|
+
"budget": {
|
|
467
|
+
"limit_tokens": budget_tokens,
|
|
468
|
+
"spent_tokens": estimate_tokens(spent_chars),
|
|
469
|
+
"spent_chars": spent_chars,
|
|
470
|
+
"exhausted": budget_exhausted,
|
|
471
|
+
},
|
|
472
|
+
"policy": {
|
|
473
|
+
"k": MAX_EVIDENCE,
|
|
474
|
+
"chunk_chars": CHUNK_CHARS,
|
|
475
|
+
"embedder": embedder.id,
|
|
476
|
+
"index_chunks": len(index["chunks"]),
|
|
477
|
+
},
|
|
478
|
+
"tools": events,
|
|
479
|
+
"paths_read": sorted({str(e["path"]) for e in evidence}),
|
|
480
|
+
"hops": [],
|
|
481
|
+
"evidence_paths": [str(e["path"]) for e in evidence],
|
|
482
|
+
"abstained": abstained,
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return AskResult(
|
|
486
|
+
question=question,
|
|
487
|
+
answer=answer,
|
|
488
|
+
abstained=abstained,
|
|
489
|
+
citations=citations,
|
|
490
|
+
# References carry no status/stale/verified metadata, so RAG cannot
|
|
491
|
+
# flag draft/deprecated/stale evidence — a measured property, not a bug.
|
|
492
|
+
warnings=[],
|
|
493
|
+
trace=trace,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def write_trace(bundle_dir: Path, result: AskResult) -> Path:
|
|
498
|
+
"""Persist the trace as derived state under ``.oknoll/traces/``."""
|
|
499
|
+
traces = bundle_dir / TRACES_DIR
|
|
500
|
+
traces.mkdir(parents=True, exist_ok=True)
|
|
501
|
+
stamp = str(result.trace.get("started_at", "")).replace(":", "").replace("-", "")
|
|
502
|
+
digest = sha256_hex(result.question)[:8]
|
|
503
|
+
base = f"ask-{stamp}-{digest}"
|
|
504
|
+
path = traces / f"{base}.json"
|
|
505
|
+
counter = 2
|
|
506
|
+
while path.exists():
|
|
507
|
+
path = traces / f"{base}-{counter}.json"
|
|
508
|
+
counter += 1
|
|
509
|
+
path.write_text(
|
|
510
|
+
json.dumps(result.to_dict(), indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
511
|
+
)
|
|
512
|
+
return path
|