data-olympus 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.
- data_olympus/__init__.py +14 -0
- data_olympus/_bin/_kb_detect_workspace.sh +57 -0
- data_olympus/_bin/_kb_enforce.py +619 -0
- data_olympus/_bin/_kb_fallback.py +361 -0
- data_olympus/_bin/kb +957 -0
- data_olympus/_bin/kb-enforce-hook +337 -0
- data_olympus/_bin/opencode/data-olympus-gate.ts +102 -0
- data_olympus/audit_log.py +275 -0
- data_olympus/audit_trailers.py +42 -0
- data_olympus/auth.py +139 -0
- data_olympus/cli/__init__.py +1 -0
- data_olympus/cli/import_cmd.py +115 -0
- data_olympus/cli/indexgen.py +60 -0
- data_olympus/cli/main.py +151 -0
- data_olympus/cli/report_cmd.py +181 -0
- data_olympus/config.py +261 -0
- data_olympus/cooccurrence.py +393 -0
- data_olympus/dedup.py +57 -0
- data_olympus/durable.py +51 -0
- data_olympus/embeddings.py +317 -0
- data_olympus/enforce_policy.py +297 -0
- data_olympus/format/__init__.py +16 -0
- data_olympus/format/document.py +39 -0
- data_olympus/format/frontmatter.py +35 -0
- data_olympus/format/lint.py +92 -0
- data_olympus/format/validate.py +71 -0
- data_olympus/git_ops.py +397 -0
- data_olympus/health.py +114 -0
- data_olympus/importer/__init__.py +13 -0
- data_olympus/importer/adr.py +286 -0
- data_olympus/importer/flat.py +170 -0
- data_olympus/importer/model.py +106 -0
- data_olympus/importer/okf.py +192 -0
- data_olympus/importer/run.py +416 -0
- data_olympus/importer/stamp.py +227 -0
- data_olympus/index.py +1745 -0
- data_olympus/markdown_parse.py +103 -0
- data_olympus/models.py +480 -0
- data_olympus/onboarding.py +131 -0
- data_olympus/onboarding_inflight.py +137 -0
- data_olympus/onboarding_playbook.py +99 -0
- data_olympus/pending.py +533 -0
- data_olympus/principals.py +168 -0
- data_olympus/prompts.py +35 -0
- data_olympus/push_queue.py +261 -0
- data_olympus/query_expansion.py +200 -0
- data_olympus/rate_limit.py +81 -0
- data_olympus/refresh.py +329 -0
- data_olympus/report.py +133 -0
- data_olympus/rest_api.py +845 -0
- data_olympus/safe_id.py +36 -0
- data_olympus/search_gate.py +64 -0
- data_olympus/search_shortcut.py +146 -0
- data_olympus/server.py +1115 -0
- data_olympus/session_metrics.py +303 -0
- data_olympus/setup_wizard.py +751 -0
- data_olympus/thin_pointer.py +20 -0
- data_olympus/tools_audit.py +41 -0
- data_olympus/tools_enforce.py +198 -0
- data_olympus/tools_onboarding.py +585 -0
- data_olympus/tools_read.py +230 -0
- data_olympus/tools_write.py +878 -0
- data_olympus/trigram.py +126 -0
- data_olympus/viewer/__init__.py +1 -0
- data_olympus/viewer/generator.py +375 -0
- data_olympus/worktrees.py +147 -0
- data_olympus/write_gate.py +382 -0
- data_olympus-0.3.0.dist-info/METADATA +97 -0
- data_olympus-0.3.0.dist-info/RECORD +73 -0
- data_olympus-0.3.0.dist-info/WHEEL +4 -0
- data_olympus-0.3.0.dist-info/entry_points.txt +3 -0
- data_olympus-0.3.0.dist-info/licenses/LICENSE +202 -0
- data_olympus-0.3.0.dist-info/licenses/NOTICE +8 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Optional local-embedding hybrid ranking (issue #42).
|
|
2
|
+
|
|
3
|
+
Real paraphrase / synonymy handling via LOCAL embeddings, blended with BM25. No
|
|
4
|
+
external API is ever called at query time; a small ONNX MiniLM-class model runs
|
|
5
|
+
in-process. The feature is OFF by default and, when off, this module is the ONLY
|
|
6
|
+
place that would touch the embedding libraries, and it imports them LAZILY
|
|
7
|
+
(inside :func:`_load_text_embedding_cls`), so with the ``embeddings`` extra NOT
|
|
8
|
+
installed and ``KB_EMBEDDINGS_MODE`` unset the default product is byte-for-byte
|
|
9
|
+
unchanged and never imports ``fastembed`` / ``onnxruntime``.
|
|
10
|
+
|
|
11
|
+
Design (mirrors ``trigram.py`` / ``cooccurrence.py``):
|
|
12
|
+
|
|
13
|
+
- ``EMBEDDINGS_SCHEMA``: the ``doc_vectors`` table DDL, appended to the index
|
|
14
|
+
schema so it is created (and, via the tmp-DB build + ``os.replace``, swapped)
|
|
15
|
+
atomically with the primary FTS table. The table is created unconditionally
|
|
16
|
+
(an empty table costs nothing) but only POPULATED when the feature is enabled,
|
|
17
|
+
so a default build needs no embedding dependency.
|
|
18
|
+
- ``embeddings_enabled`` / ``embeddings_config``: env config. ``KB_EMBEDDINGS_MODE``
|
|
19
|
+
gates the whole feature (default off); ``KB_EMBEDDINGS_MODEL`` and
|
|
20
|
+
``KB_EMBEDDINGS_WEIGHT`` tune the model and blend weight.
|
|
21
|
+
- ``build_embedder``: construct the local embedder, raising a LOUD, actionable
|
|
22
|
+
:class:`EmbeddingsUnavailableError` when the dep/model is missing rather than
|
|
23
|
+
silently falling back (which would hide misconfiguration).
|
|
24
|
+
- ``serialize_vector`` / ``deserialize_vector`` / ``cosine``: pure-Python vector
|
|
25
|
+
storage + similarity, so the query-time blend needs no numpy.
|
|
26
|
+
- ``make_hybrid_reranker``: blend NORMALISED bm25 with cosine over candidate
|
|
27
|
+
hits' stored vectors and re-sort. Composed as an INNER reranker under the
|
|
28
|
+
id/tag short-circuit in ``server.build_app`` so an exact id/tag still wins.
|
|
29
|
+
|
|
30
|
+
Ranking discipline: the hybrid reranker only RE-ORDERS the existing candidate
|
|
31
|
+
pool; it never drops a hit (a doc with no stored vector keeps its bm25
|
|
32
|
+
contribution and a neutral cosine of 0). Because it is composed as ``inner``
|
|
33
|
+
under the id/tag short-circuit, an exact-id or exact-tag query is unaffected.
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import array
|
|
38
|
+
import math
|
|
39
|
+
import os
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from typing import TYPE_CHECKING, Any
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from collections.abc import Callable
|
|
45
|
+
|
|
46
|
+
from data_olympus.index import SearchHit
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --- config ------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
# A small, local, ONNX MiniLM-class model. bge-small-en-v1.5 is 384-dim, emits
|
|
52
|
+
# L2-normalised vectors, and ships as a quantised ONNX file (~33 MB) that
|
|
53
|
+
# fastembed fetches once and caches; it needs no torch. A deployment can point
|
|
54
|
+
# ``KB_EMBEDDINGS_MODEL`` at any fastembed-supported model.
|
|
55
|
+
DEFAULT_MODEL_NAME = "BAAI/bge-small-en-v1.5"
|
|
56
|
+
|
|
57
|
+
# Blend weight in [0, 1]: the fraction of the final score contributed by cosine
|
|
58
|
+
# similarity; ``1 - weight`` is contributed by normalised bm25. 0.0 == pure
|
|
59
|
+
# lexical (bm25) ordering, 1.0 == pure semantic (cosine). The default leans on
|
|
60
|
+
# bm25 (the tuned lexical signal) while letting cosine rescue paraphrase misses.
|
|
61
|
+
DEFAULT_WEIGHT = 0.35
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class EmbeddingsConfig:
|
|
66
|
+
"""Resolved embeddings configuration (model + blend weight)."""
|
|
67
|
+
|
|
68
|
+
model_name: str
|
|
69
|
+
weight: float
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class EmbeddingsUnavailableError(RuntimeError):
|
|
73
|
+
"""Raised when embeddings are ENABLED but the dep/model cannot be loaded.
|
|
74
|
+
|
|
75
|
+
Carries an actionable message so a misconfigured deployment fails loudly at
|
|
76
|
+
startup instead of silently shipping default (lexical-only) ranking.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def embeddings_enabled() -> bool:
|
|
81
|
+
"""Whether local-embedding hybrid ranking is active. Default OFF.
|
|
82
|
+
|
|
83
|
+
``KB_EMBEDDINGS_MODE=on`` (case-insensitive) enables it; any other value
|
|
84
|
+
(including unset) leaves it off so the zero-dependency lexical product is
|
|
85
|
+
unchanged and the embedding libraries are never imported.
|
|
86
|
+
"""
|
|
87
|
+
return os.getenv("KB_EMBEDDINGS_MODE", "off").strip().lower() == "on"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def embeddings_config() -> EmbeddingsConfig:
|
|
91
|
+
"""Resolve the embeddings config from env, applying defaults.
|
|
92
|
+
|
|
93
|
+
``KB_EMBEDDINGS_WEIGHT`` must parse to a float in [0, 1]; a malformed or
|
|
94
|
+
out-of-range value raises ``ValueError`` rather than silently clamping, so a
|
|
95
|
+
misconfigured blend fails loudly (matching ``KB_STATUS_WEIGHTS`` semantics).
|
|
96
|
+
"""
|
|
97
|
+
model_name = os.getenv("KB_EMBEDDINGS_MODEL", "").strip() or DEFAULT_MODEL_NAME
|
|
98
|
+
raw_weight = os.getenv("KB_EMBEDDINGS_WEIGHT", "").strip()
|
|
99
|
+
if not raw_weight:
|
|
100
|
+
weight = DEFAULT_WEIGHT
|
|
101
|
+
else:
|
|
102
|
+
try:
|
|
103
|
+
weight = float(raw_weight)
|
|
104
|
+
except ValueError as e:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"KB_EMBEDDINGS_WEIGHT must be a float in [0, 1]; got {raw_weight!r}"
|
|
107
|
+
) from e
|
|
108
|
+
if not 0.0 <= weight <= 1.0:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"KB_EMBEDDINGS_WEIGHT must be in [0, 1]; got {weight}"
|
|
111
|
+
)
|
|
112
|
+
return EmbeddingsConfig(model_name=model_name, weight=weight)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# --- vector storage + similarity (pure Python, no numpy) ---------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def serialize_vector(vec: list[float]) -> bytes:
|
|
119
|
+
"""Pack a float vector into a compact little-endian float32 blob.
|
|
120
|
+
|
|
121
|
+
float32 halves the stored size versus float64 at a precision loss well below
|
|
122
|
+
the ranking signal. ``array`` keeps this dependency-free; the blob round-trips
|
|
123
|
+
through :func:`deserialize_vector`.
|
|
124
|
+
"""
|
|
125
|
+
return array.array("f", vec).tobytes()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def deserialize_vector(blob: bytes) -> list[float]:
|
|
129
|
+
"""Unpack a float32 blob written by :func:`serialize_vector`."""
|
|
130
|
+
a = array.array("f")
|
|
131
|
+
a.frombytes(blob)
|
|
132
|
+
return list(a)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cosine(a: list[float], b: list[float]) -> float:
|
|
136
|
+
"""Cosine similarity of two equal-length vectors, in [-1, 1].
|
|
137
|
+
|
|
138
|
+
A zero-norm vector has no direction, so its similarity is defined as 0 rather
|
|
139
|
+
than dividing by zero. A length mismatch is a programming error and raises.
|
|
140
|
+
"""
|
|
141
|
+
if len(a) != len(b):
|
|
142
|
+
raise ValueError(f"vector length mismatch: {len(a)} != {len(b)}")
|
|
143
|
+
dot = 0.0
|
|
144
|
+
na = 0.0
|
|
145
|
+
nb = 0.0
|
|
146
|
+
for x, y in zip(a, b, strict=True):
|
|
147
|
+
dot += x * y
|
|
148
|
+
na += x * x
|
|
149
|
+
nb += y * y
|
|
150
|
+
if na == 0.0 or nb == 0.0:
|
|
151
|
+
return 0.0
|
|
152
|
+
return dot / (math.sqrt(na) * math.sqrt(nb))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --- embedder (lazy fastembed import) ----------------------------------------
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _load_text_embedding_cls() -> Any:
|
|
159
|
+
"""Import and return ``fastembed.TextEmbedding``.
|
|
160
|
+
|
|
161
|
+
Isolated so it can be monkeypatched in tests and so the import stays LAZY:
|
|
162
|
+
it runs only from :func:`build_embedder`, which is only reached when the
|
|
163
|
+
feature is enabled. With the feature off this line is never executed and
|
|
164
|
+
``fastembed`` is never imported.
|
|
165
|
+
"""
|
|
166
|
+
from fastembed import TextEmbedding
|
|
167
|
+
|
|
168
|
+
return TextEmbedding
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class Embedder:
|
|
172
|
+
"""Thin wrapper over a local fastembed model.
|
|
173
|
+
|
|
174
|
+
Holds the loaded model and exposes ``embed_one`` (query) and ``embed_many``
|
|
175
|
+
(build) returning plain ``list[float]`` so the rest of the codebase stays
|
|
176
|
+
numpy-free. Construction is via :func:`build_embedder`.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def __init__(self, model: Any, *, model_name: str) -> None:
|
|
180
|
+
self._model = model
|
|
181
|
+
self.model_name = model_name
|
|
182
|
+
|
|
183
|
+
def embed_one(self, text: str) -> list[float]:
|
|
184
|
+
"""Embed a single text into a plain float list."""
|
|
185
|
+
for vec in self._model.embed([text]):
|
|
186
|
+
return [float(x) for x in vec]
|
|
187
|
+
return []
|
|
188
|
+
|
|
189
|
+
def embed_many(self, texts: list[str]) -> list[list[float]]:
|
|
190
|
+
"""Embed a batch, preserving input order."""
|
|
191
|
+
return [[float(x) for x in vec] for vec in self._model.embed(texts)]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def build_embedder(config: EmbeddingsConfig) -> Embedder:
|
|
195
|
+
"""Load the local embedding model, or fail loudly.
|
|
196
|
+
|
|
197
|
+
Raises :class:`EmbeddingsUnavailableError` with an actionable message when
|
|
198
|
+
the ``embeddings`` extra is not installed or the model cannot be loaded
|
|
199
|
+
(e.g. it could not be fetched to the local cache). The caller (startup path)
|
|
200
|
+
surfaces this so a misconfigured deployment fails visibly rather than
|
|
201
|
+
silently reverting to lexical-only ranking.
|
|
202
|
+
"""
|
|
203
|
+
try:
|
|
204
|
+
text_embedding_cls = _load_text_embedding_cls()
|
|
205
|
+
except ImportError as e:
|
|
206
|
+
raise EmbeddingsUnavailableError(
|
|
207
|
+
"KB_EMBEDDINGS_MODE is on but the embeddings dependency is not "
|
|
208
|
+
"installed. Install the extra: `uv sync --extra embeddings` (or "
|
|
209
|
+
"`pip install 'data-olympus[embeddings]'`), or set KB_EMBEDDINGS_MODE=off "
|
|
210
|
+
f"to run lexical-only. Underlying import error: {e}"
|
|
211
|
+
) from e
|
|
212
|
+
try:
|
|
213
|
+
model = text_embedding_cls(model_name=config.model_name)
|
|
214
|
+
except Exception as e: # noqa: BLE001 - any load failure must fail loudly
|
|
215
|
+
raise EmbeddingsUnavailableError(
|
|
216
|
+
f"KB_EMBEDDINGS_MODE is on but the embedding model "
|
|
217
|
+
f"{config.model_name!r} could not be loaded (it is fetched once and "
|
|
218
|
+
f"cached; a first-run needs the model available). Set a cached "
|
|
219
|
+
f"KB_EMBEDDINGS_MODEL or KB_EMBEDDINGS_MODE=off. Underlying error: {e}"
|
|
220
|
+
) from e
|
|
221
|
+
return Embedder(model, model_name=config.model_name)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# --- SQLite persistence (built inside Index.build, read at query time) -------
|
|
225
|
+
|
|
226
|
+
# One row per doc: the id (FK to docs.id) and its float32 vector blob. Created
|
|
227
|
+
# unconditionally in the schema (empty when the feature is off) so a default
|
|
228
|
+
# build needs no embedding dep; populated only when embeddings are enabled.
|
|
229
|
+
EMBEDDINGS_SCHEMA = """
|
|
230
|
+
CREATE TABLE IF NOT EXISTS doc_vectors (
|
|
231
|
+
id TEXT PRIMARY KEY,
|
|
232
|
+
vector BLOB NOT NULL
|
|
233
|
+
);
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# --- hybrid reranker ---------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _normalise_bm25(hits: list[SearchHit]) -> dict[str, float]:
|
|
241
|
+
"""Map each hit id to a bm25 GOODNESS in [0, 1] (1 == best in this pool).
|
|
242
|
+
|
|
243
|
+
search() orders bm25 ASCENDING (lower is better), so we invert: the minimum
|
|
244
|
+
(best) score maps to 1.0 and the maximum (worst) to 0.0. When all scores are
|
|
245
|
+
equal (single hit, or a synthetic neutral pool) every hit gets 1.0, so the
|
|
246
|
+
blend is decided purely by cosine, and there is no divide-by-zero.
|
|
247
|
+
"""
|
|
248
|
+
if not hits:
|
|
249
|
+
return {}
|
|
250
|
+
scores = [h.score for h in hits]
|
|
251
|
+
lo = min(scores)
|
|
252
|
+
hi = max(scores)
|
|
253
|
+
spread = hi - lo
|
|
254
|
+
if spread == 0.0:
|
|
255
|
+
return {h.id: 1.0 for h in hits}
|
|
256
|
+
# goodness = 1 when score == lo (best), 0 when score == hi (worst).
|
|
257
|
+
return {h.id: (hi - h.score) / spread for h in hits}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def make_hybrid_reranker(
|
|
261
|
+
*,
|
|
262
|
+
embed_query: Callable[[str], list[float] | None],
|
|
263
|
+
get_vector: Callable[[str], list[float] | None],
|
|
264
|
+
weight: float,
|
|
265
|
+
) -> Callable[[str, list[SearchHit]], list[SearchHit]]:
|
|
266
|
+
"""Build a reranker that blends normalised bm25 with query-doc cosine.
|
|
267
|
+
|
|
268
|
+
``embed_query(query)`` returns the query vector (or ``None`` if it cannot be
|
|
269
|
+
embedded); ``get_vector(id)`` returns a stored doc vector (or ``None`` when
|
|
270
|
+
the doc has no vector). ``weight`` in [0, 1] is the cosine fraction of the
|
|
271
|
+
blended score (``1 - weight`` is bm25).
|
|
272
|
+
|
|
273
|
+
The blended score is::
|
|
274
|
+
|
|
275
|
+
blended = (1 - weight) * bm25_goodness + weight * cosine_component
|
|
276
|
+
|
|
277
|
+
where ``bm25_goodness`` is in [0, 1] (higher = better within this pool) and
|
|
278
|
+
``cosine_component`` maps cosine [-1, 1] to [0, 1]. Hits are re-sorted by
|
|
279
|
+
blended score DESCENDING (higher = better) and the result is re-scored onto
|
|
280
|
+
the search()-native ascending-bm25 convention (negated blended score) so a
|
|
281
|
+
later stage that sorts ascending keeps the same order.
|
|
282
|
+
|
|
283
|
+
Discipline: no hit is dropped. A doc with no stored vector, or a query that
|
|
284
|
+
cannot be embedded, contributes a neutral cosine (the reranker degrades to
|
|
285
|
+
bm25 ordering for that case). The re-sort is stable for equal blended scores.
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
def reranker(query: str, hits: list[SearchHit]) -> list[SearchHit]:
|
|
289
|
+
if not hits:
|
|
290
|
+
return hits
|
|
291
|
+
from dataclasses import replace
|
|
292
|
+
|
|
293
|
+
qvec = embed_query(query)
|
|
294
|
+
bm25_goodness = _normalise_bm25(hits)
|
|
295
|
+
|
|
296
|
+
def blended(h: SearchHit) -> float:
|
|
297
|
+
good = bm25_goodness.get(h.id, 0.0)
|
|
298
|
+
cos_component = 0.0
|
|
299
|
+
if qvec is not None:
|
|
300
|
+
dvec = get_vector(h.id)
|
|
301
|
+
if dvec is not None and len(dvec) == len(qvec):
|
|
302
|
+
# Map cosine [-1, 1] -> [0, 1].
|
|
303
|
+
cos_component = (cosine(qvec, dvec) + 1.0) / 2.0
|
|
304
|
+
return (1.0 - weight) * good + weight * cos_component
|
|
305
|
+
|
|
306
|
+
scored = [(blended(h), i, h) for i, h in enumerate(hits)]
|
|
307
|
+
# Sort by rank_class FIRST (primaries before backfill), then blended DESC;
|
|
308
|
+
# ties keep incoming order via the enumerate index. The rank-class outer
|
|
309
|
+
# key means the cosine blend can re-order WITHIN a class but never lift a
|
|
310
|
+
# backfill (expansion/trigram) hit above a primary (finding (d)): the
|
|
311
|
+
# blend re-normalises scores, so a score-only floor would not hold.
|
|
312
|
+
scored.sort(key=lambda t: (t[2].rank_class, -t[0], t[1]))
|
|
313
|
+
# Re-score onto the ascending-bm25 convention (lower == better) so a
|
|
314
|
+
# downstream ascending sort agrees with this order: negate blended.
|
|
315
|
+
return [replace(h, score=-b) for b, _i, h in scored]
|
|
316
|
+
|
|
317
|
+
return reranker
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Policy core for enforcement: the heuristic intent classifier and the
|
|
2
|
+
in-memory consultation ledger. Pure and dependency-free so it is unit-testable
|
|
3
|
+
without a FastMCP server."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import contextlib
|
|
7
|
+
import fnmatch
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
# Keyword signals that a user prompt is a governed code/architectural decision.
|
|
17
|
+
GOVERNED_KEYWORDS: tuple[str, ...] = (
|
|
18
|
+
"library", "dependency", "dependencies", "framework", "package",
|
|
19
|
+
"pattern", "migration", "migrate", "refactor", "architecture",
|
|
20
|
+
"api design", "endpoint", "schema", "auth", "authorization",
|
|
21
|
+
"authentication", "rls", "secret", "convention", "standard",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Path globs that mark a pending file action as a governed decision. Deliberately
|
|
25
|
+
# narrow (dependency manifests, migrations, schema, container/build config) so
|
|
26
|
+
# that ordinary source edits flow without a consult; the prompt-level classifier
|
|
27
|
+
# carries the broad net.
|
|
28
|
+
GOVERNED_PATH_GLOBS: tuple[str, ...] = (
|
|
29
|
+
"pyproject.toml", "*/pyproject.toml",
|
|
30
|
+
"package.json", "*/package.json",
|
|
31
|
+
"*/requirements*.txt", "requirements*.txt",
|
|
32
|
+
"*/go.mod", "go.mod", "*/Cargo.toml", "Cargo.toml", "*/pom.xml",
|
|
33
|
+
"*/migrations/*", "*/migration/*",
|
|
34
|
+
"*/schema/*", "*/schema.sql", "*.sql",
|
|
35
|
+
"Dockerfile", "*/Dockerfile", "*/docker-compose*.yml", "docker-compose*.yml",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Command fragments (matched against action_diff) that indicate a governed
|
|
39
|
+
# dependency/install action, so Bash/shell tool actions can be classified.
|
|
40
|
+
GOVERNED_COMMAND_PATTERNS: tuple[str, ...] = (
|
|
41
|
+
"pip install", "pip3 install", "uv add", "uv pip install", "poetry add",
|
|
42
|
+
"npm install", "npm i ", "yarn add", "pnpm add",
|
|
43
|
+
"apt install", "apt-get install", "brew install",
|
|
44
|
+
"go get", "cargo add", "gem install", "bundle add",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ClassifyResult:
|
|
50
|
+
"""Outcome of a classification: governed or not, plus the matched signals."""
|
|
51
|
+
|
|
52
|
+
is_governed_decision: bool
|
|
53
|
+
signals: list[str] = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class IntentClassifier:
|
|
57
|
+
"""Heuristic classifier. Pluggable: a future LLM-backed classifier can
|
|
58
|
+
implement the same ``classify`` signature without touching callers."""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
*,
|
|
63
|
+
keywords: tuple[str, ...] = GOVERNED_KEYWORDS,
|
|
64
|
+
path_globs: tuple[str, ...] = GOVERNED_PATH_GLOBS,
|
|
65
|
+
command_patterns: tuple[str, ...] = GOVERNED_COMMAND_PATTERNS,
|
|
66
|
+
) -> None:
|
|
67
|
+
self._keywords = tuple(k.lower() for k in keywords)
|
|
68
|
+
self._path_globs = tuple(path_globs)
|
|
69
|
+
self._command_patterns = tuple(p.lower() for p in command_patterns)
|
|
70
|
+
# Pre-compile a word-boundary regex per keyword so "authored" does not
|
|
71
|
+
# match "auth". \b around each keyword; keywords with spaces still work.
|
|
72
|
+
self._keyword_res = tuple(
|
|
73
|
+
(kw, re.compile(rf"\b{re.escape(kw)}\b")) for kw in self._keywords
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def classify(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
intent: str = "",
|
|
80
|
+
action_path: str | None = None,
|
|
81
|
+
action_diff: str = "",
|
|
82
|
+
) -> ClassifyResult:
|
|
83
|
+
signals: list[str] = []
|
|
84
|
+
text = f"{intent} {action_diff}".lower()
|
|
85
|
+
for kw, rx in self._keyword_res:
|
|
86
|
+
if rx.search(text):
|
|
87
|
+
signals.append(f"keyword:{kw}")
|
|
88
|
+
diff_lower = action_diff.lower()
|
|
89
|
+
for pat in self._command_patterns:
|
|
90
|
+
if pat in diff_lower:
|
|
91
|
+
signals.append(f"command:{pat.strip()}")
|
|
92
|
+
if action_path:
|
|
93
|
+
p = action_path.replace("\\", "/")
|
|
94
|
+
base = p.rsplit("/", 1)[-1]
|
|
95
|
+
for glob in self._path_globs:
|
|
96
|
+
if fnmatch.fnmatch(p, glob) or fnmatch.fnmatch(base, glob):
|
|
97
|
+
signals.append(f"path:{glob}")
|
|
98
|
+
break
|
|
99
|
+
return ClassifyResult(is_governed_decision=bool(signals), signals=signals)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# Consultation trigger provenance. "explicit" is a deliberate agent call to
|
|
103
|
+
# kb_consult (the MCP tool default, and any old client that sends no trigger).
|
|
104
|
+
# "prompt_hook" is an installer-driven UserPromptSubmit/BeforeAgent auto-consult:
|
|
105
|
+
# recorded for audit/compliance but NOT sufficient to clear the gate, because it
|
|
106
|
+
# fires on every user turn and would otherwise keep the ledger perpetually fresh.
|
|
107
|
+
EXPLICIT_TRIGGER = "explicit"
|
|
108
|
+
PROMPT_HOOK_TRIGGER = "prompt_hook"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class LedgerEntry:
|
|
113
|
+
"""A recorded consultation for a (session, workspace) pair.
|
|
114
|
+
|
|
115
|
+
``consulted_at`` is the last touch of ANY trigger (used for retention/TTL of
|
|
116
|
+
the row itself and audit). ``explicit_at`` is the last EXPLICIT consult, or
|
|
117
|
+
None if only prompt-hook auto-consults have been recorded. The gate clears on
|
|
118
|
+
``explicit_at`` freshness so a prompt-hook auto-consult (which fires every
|
|
119
|
+
turn) can never satisfy the gate, while an explicit consult is not silently
|
|
120
|
+
downgraded by a later prompt-hook consult on the same (session, workspace)."""
|
|
121
|
+
|
|
122
|
+
consulted_at: float
|
|
123
|
+
rule_ids: list[str]
|
|
124
|
+
explicit_at: float | None = None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
log = logging.getLogger("data_olympus")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ConsultationLedger:
|
|
131
|
+
"""Records which (session, workspace) pairs consulted and when.
|
|
132
|
+
|
|
133
|
+
With no ``path`` it is purely in-memory (the slice-1 behavior). With a
|
|
134
|
+
``path`` it loads an existing JSON file on construction and rewrites it on
|
|
135
|
+
every ``record`` so consultations survive a server restart. A corrupt or
|
|
136
|
+
unreadable file degrades to empty with a logged warning and never crashes."""
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
path: str | None = None,
|
|
141
|
+
*,
|
|
142
|
+
retention_sec: float = 3600.0,
|
|
143
|
+
max_entries: int = 50_000,
|
|
144
|
+
) -> None:
|
|
145
|
+
self._path = path
|
|
146
|
+
self._entries: dict[tuple[str, str], LedgerEntry] = {}
|
|
147
|
+
# An entry is a TTL freshness cache: once it is older than the consult
|
|
148
|
+
# TTL it can never be fresh again, so we evict it on the next record().
|
|
149
|
+
# ``retention_sec`` should be >= the ttl_sec passed to is_fresh (the
|
|
150
|
+
# server threads config.consult_ttl_sec in) so a still-fresh entry is
|
|
151
|
+
# never dropped. ``max_entries`` is a hard belt-and-suspenders cap that
|
|
152
|
+
# bounds memory/disk even under a flood of unique sessions inside one
|
|
153
|
+
# retention window. Together they stop _entries growing without bound and
|
|
154
|
+
# keep the per-record file rewrite O(active window) instead of O(all
|
|
155
|
+
# sessions ever seen).
|
|
156
|
+
self._retention_sec = retention_sec
|
|
157
|
+
self._max_entries = max_entries
|
|
158
|
+
# record() mutates _entries and rewrites the whole file; once consult
|
|
159
|
+
# handlers are offloaded to the anyio threadpool these can run
|
|
160
|
+
# concurrently. Without this lock, _save()'s iteration over _entries can
|
|
161
|
+
# race a concurrent insert (RuntimeError / dropped entries). Public
|
|
162
|
+
# methods take the lock; _save()/_evict() do not (called only while held).
|
|
163
|
+
self._lock = threading.Lock()
|
|
164
|
+
if path:
|
|
165
|
+
self._load()
|
|
166
|
+
# A ledger persisted by the previous unbounded implementation can be
|
|
167
|
+
# arbitrarily large; cap it on load so an oversized /state/ledger.json
|
|
168
|
+
# is not held in memory until the first record() prunes it. TTL
|
|
169
|
+
# eviction still runs on the first record() (it needs a caller "now").
|
|
170
|
+
self._enforce_cap()
|
|
171
|
+
|
|
172
|
+
def _load(self) -> None:
|
|
173
|
+
if not self._path or not os.path.exists(self._path):
|
|
174
|
+
return
|
|
175
|
+
try:
|
|
176
|
+
with open(self._path, encoding="utf-8") as f:
|
|
177
|
+
rows = json.load(f)
|
|
178
|
+
for row in rows:
|
|
179
|
+
key = (row["session_id"], row["workspace"])
|
|
180
|
+
# Back-compat: a ledger persisted before the trigger split has no
|
|
181
|
+
# explicit_at. Treat those legacy rows as explicit (they were all
|
|
182
|
+
# gate-clearing under the old policy), so a server upgrade does not
|
|
183
|
+
# spuriously re-block a session that already consulted.
|
|
184
|
+
consulted_at = float(row["consulted_at"])
|
|
185
|
+
explicit_at = row.get("explicit_at", consulted_at)
|
|
186
|
+
self._entries[key] = LedgerEntry(
|
|
187
|
+
consulted_at=consulted_at,
|
|
188
|
+
rule_ids=list(row.get("rule_ids", [])),
|
|
189
|
+
explicit_at=None if explicit_at is None else float(explicit_at),
|
|
190
|
+
)
|
|
191
|
+
except Exception as exc: # noqa: BLE001 - corrupt file -> empty, never crash
|
|
192
|
+
log.warning("consultation ledger at %s unreadable, starting empty: %s",
|
|
193
|
+
self._path, exc)
|
|
194
|
+
self._entries = {}
|
|
195
|
+
|
|
196
|
+
def _save(self) -> None:
|
|
197
|
+
if not self._path:
|
|
198
|
+
return
|
|
199
|
+
rows = [
|
|
200
|
+
{"session_id": s, "workspace": w,
|
|
201
|
+
"consulted_at": e.consulted_at, "rule_ids": e.rule_ids,
|
|
202
|
+
"explicit_at": e.explicit_at}
|
|
203
|
+
for (s, w), e in self._entries.items()
|
|
204
|
+
]
|
|
205
|
+
d = os.path.dirname(self._path) or "."
|
|
206
|
+
os.makedirs(d, exist_ok=True)
|
|
207
|
+
# Atomic write: serialize to a temp file in the same directory, then
|
|
208
|
+
# os.replace() over the target so a crash/full-disk mid-write cannot
|
|
209
|
+
# truncate or corrupt the existing ledger.
|
|
210
|
+
fd, tmp = tempfile.mkstemp(dir=d, prefix=".ledger-", suffix=".tmp")
|
|
211
|
+
try:
|
|
212
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
213
|
+
json.dump(rows, f)
|
|
214
|
+
os.replace(tmp, self._path)
|
|
215
|
+
except Exception:
|
|
216
|
+
with contextlib.suppress(OSError):
|
|
217
|
+
os.unlink(tmp)
|
|
218
|
+
raise
|
|
219
|
+
|
|
220
|
+
def _evict(self, now: float) -> None:
|
|
221
|
+
"""Drop entries that can no longer be fresh, then enforce the hard cap.
|
|
222
|
+
|
|
223
|
+
Caller must hold ``self._lock``. Reassigns ``self._entries`` to a pruned
|
|
224
|
+
dict; O(n) but n is exactly what this bounds.
|
|
225
|
+
"""
|
|
226
|
+
cutoff = now - self._retention_sec
|
|
227
|
+
self._entries = {
|
|
228
|
+
key: e for key, e in self._entries.items() if e.consulted_at >= cutoff
|
|
229
|
+
}
|
|
230
|
+
self._enforce_cap()
|
|
231
|
+
|
|
232
|
+
def _enforce_cap(self) -> None:
|
|
233
|
+
"""Bound _entries to the newest ``max_entries`` by consulted_at. Clock-free
|
|
234
|
+
so it can run on load (before any caller "now" is available). Caller holds
|
|
235
|
+
the lock, except the single-threaded construction-time call."""
|
|
236
|
+
if len(self._entries) > self._max_entries:
|
|
237
|
+
newest = sorted(
|
|
238
|
+
self._entries.items(), key=lambda kv: kv[1].consulted_at, reverse=True
|
|
239
|
+
)[: self._max_entries]
|
|
240
|
+
self._entries = dict(newest)
|
|
241
|
+
|
|
242
|
+
def record(
|
|
243
|
+
self,
|
|
244
|
+
*,
|
|
245
|
+
session_id: str,
|
|
246
|
+
workspace: str,
|
|
247
|
+
rule_ids: list[str],
|
|
248
|
+
now: float,
|
|
249
|
+
trigger: str = EXPLICIT_TRIGGER,
|
|
250
|
+
) -> None:
|
|
251
|
+
"""Record a consultation. ``trigger`` is EXPLICIT_TRIGGER (a deliberate
|
|
252
|
+
agent consult that clears the gate) or PROMPT_HOOK_TRIGGER (an installer
|
|
253
|
+
auto-consult that is audited but never clears the gate).
|
|
254
|
+
|
|
255
|
+
A prompt-hook record refreshes ``consulted_at`` (row liveness/audit) but
|
|
256
|
+
carries forward any existing ``explicit_at`` so it cannot downgrade a
|
|
257
|
+
still-fresh explicit consult into a non-clearing one."""
|
|
258
|
+
with self._lock:
|
|
259
|
+
prior = self._entries.get((session_id, workspace))
|
|
260
|
+
if trigger == EXPLICIT_TRIGGER:
|
|
261
|
+
explicit_at: float | None = now
|
|
262
|
+
else:
|
|
263
|
+
explicit_at = prior.explicit_at if prior is not None else None
|
|
264
|
+
self._entries[(session_id, workspace)] = LedgerEntry(
|
|
265
|
+
consulted_at=now, rule_ids=list(rule_ids), explicit_at=explicit_at
|
|
266
|
+
)
|
|
267
|
+
self._evict(now)
|
|
268
|
+
self._save()
|
|
269
|
+
|
|
270
|
+
def is_fresh(
|
|
271
|
+
self,
|
|
272
|
+
*,
|
|
273
|
+
session_id: str,
|
|
274
|
+
workspace: str,
|
|
275
|
+
now: float,
|
|
276
|
+
ttl_sec: float,
|
|
277
|
+
require_explicit: bool = True,
|
|
278
|
+
) -> bool:
|
|
279
|
+
"""True when a fresh consult is on record for (session, workspace).
|
|
280
|
+
|
|
281
|
+
With ``require_explicit`` (the gate's default) only an explicit consult
|
|
282
|
+
within ``ttl_sec`` counts: a prompt-hook auto-consult never clears the
|
|
283
|
+
gate. With ``require_explicit=False`` any consult (explicit or prompt
|
|
284
|
+
hook) within the TTL counts (used where the mere fact of a recent consult,
|
|
285
|
+
not its provenance, matters)."""
|
|
286
|
+
with self._lock:
|
|
287
|
+
entry = self._entries.get((session_id, workspace))
|
|
288
|
+
if entry is None:
|
|
289
|
+
return False
|
|
290
|
+
ts = entry.explicit_at if require_explicit else entry.consulted_at
|
|
291
|
+
if ts is None:
|
|
292
|
+
return False
|
|
293
|
+
return (now - ts) <= ttl_sec
|
|
294
|
+
|
|
295
|
+
def get(self, *, session_id: str, workspace: str) -> LedgerEntry | None:
|
|
296
|
+
with self._lock:
|
|
297
|
+
return self._entries.get((session_id, workspace))
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Public API for the data-olympus format core."""
|
|
2
|
+
|
|
3
|
+
from .document import Document
|
|
4
|
+
from .frontmatter import parse_frontmatter
|
|
5
|
+
from .lint import discover_bundle_files, lint_bundle, lint_files
|
|
6
|
+
from .validate import Finding, validate_document
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Document",
|
|
10
|
+
"Finding",
|
|
11
|
+
"parse_frontmatter",
|
|
12
|
+
"validate_document",
|
|
13
|
+
"lint_bundle",
|
|
14
|
+
"discover_bundle_files",
|
|
15
|
+
"lint_files",
|
|
16
|
+
]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""The Document model: a parsed concept file (frontmatter + body)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .frontmatter import parse_frontmatter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Document:
|
|
14
|
+
path: Path
|
|
15
|
+
frontmatter: dict[str, Any]
|
|
16
|
+
body: str
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def load(cls, path: str | Path) -> Document:
|
|
20
|
+
"""Load a document from disk. Callers must not mutate `frontmatter` in place."""
|
|
21
|
+
p = Path(path)
|
|
22
|
+
fm, body = parse_frontmatter(p.read_text(encoding="utf-8"))
|
|
23
|
+
return cls(path=p, frontmatter=fm, body=body)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def id(self) -> str | None:
|
|
27
|
+
return self.frontmatter.get("id")
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def type(self) -> str | None:
|
|
31
|
+
return self.frontmatter.get("type")
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def status(self) -> str | None:
|
|
35
|
+
return self.frontmatter.get("status")
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def tier(self) -> str | None:
|
|
39
|
+
return self.frontmatter.get("tier")
|