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 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