org-knowledge-layer 0.1.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.
Files changed (67) hide show
  1. okl/__init__.py +12 -0
  2. okl/__main__.py +8 -0
  3. okl/bootstrap.py +83 -0
  4. okl/cli.py +484 -0
  5. okl/client.py +160 -0
  6. okl/core.py +223 -0
  7. okl/drift.py +119 -0
  8. okl/mcp_server.py +75 -0
  9. okl/scaffold/MANIFEST.md +59 -0
  10. okl/scaffold/ci/method-gates.yml +32 -0
  11. okl/scaffold/ci/okl-verify.yml +59 -0
  12. okl/scaffold/claude/agents/architecture-reviewer.md +41 -0
  13. okl/scaffold/claude/commands/check-rules.md +24 -0
  14. okl/scaffold/claude/commands/feature-spec.md +37 -0
  15. okl/scaffold/claude/rules/example-area.md +22 -0
  16. okl/scaffold/claude/skills/RECOMMENDED-COMPANIONS.md +40 -0
  17. okl/scaffold/claude/skills/encoding-loop/SKILL.md +48 -0
  18. okl/scaffold/claude/skills/verify-before-claiming/SKILL.md +56 -0
  19. okl/scaffold/evals/README.md +32 -0
  20. okl/scaffold/evals/cases.jsonl +1 -0
  21. okl/scaffold/evals/run_evals.py +109 -0
  22. okl/scaffold/gates/check-canon-size.sh +11 -0
  23. okl/scaffold/gates/check-doc-orphans.sh +19 -0
  24. okl/scaffold/gates/check-retractions.sh +22 -0
  25. okl/scaffold/gates/check-tombstones.sh +22 -0
  26. okl/scaffold/gates/run-gates.sh +31 -0
  27. okl/scaffold/hooks/hooks.json +16 -0
  28. okl/scaffold/hooks/stop-okl-encode.sh +78 -0
  29. okl/scaffold/hooks/userpromptsubmit-okl-check.sh +68 -0
  30. okl/scaffold/plugin/plugin.json +10 -0
  31. okl/scaffold/profiles/dotnet/README.md +12 -0
  32. okl/scaffold/profiles/dotnet/rules/architecture.md +55 -0
  33. okl/scaffold/profiles/dotnet/rules/messaging.md +31 -0
  34. okl/scaffold/profiles/dotnet/rules/performance-and-data.md +36 -0
  35. okl/scaffold/profiles/dotnet/rules/security.md +42 -0
  36. okl/scaffold/profiles/geospatial/README.md +6 -0
  37. okl/scaffold/profiles/geospatial/rules/geospatial-ml.md +38 -0
  38. okl/scaffold/profiles/python-rag/README.md +13 -0
  39. okl/scaffold/profiles/python-rag/rules/fastapi-backend.md +37 -0
  40. okl/scaffold/profiles/python-rag/rules/project-structure.md +28 -0
  41. okl/scaffold/profiles/python-rag/rules/rag-pipeline.md +73 -0
  42. okl/scaffold/profiles/react/README.md +18 -0
  43. okl/scaffold/profiles/react/rules/frontend.md +57 -0
  44. okl/scaffold/registries/RETRACTIONS.md +19 -0
  45. okl/scaffold/registries/tombstones.txt +7 -0
  46. okl/scaffold/root/CLAUDE.md +55 -0
  47. okl/scaffold/root/METHOD.md +64 -0
  48. okl/scaffold_cmd.py +110 -0
  49. okl/seed/dotnet-canon.json +489 -0
  50. okl/seed/dotnet-decisions.json +328 -0
  51. okl/seed/dotnet-defects.json +133 -0
  52. okl/seed/dotnet-review-surfaces.json +147 -0
  53. okl/seed/frontend-canon.json +116 -0
  54. okl/seed/geospatial-deeptime-defects.json +59 -0
  55. okl/seed/geospatial-defects.json +154 -0
  56. okl/seed/geospatial-enforcement-defects.json +121 -0
  57. okl/seed/geospatial-eval-defects.json +25 -0
  58. okl/seed/rag-defects.json +120 -0
  59. okl/seed/react-defects.json +45 -0
  60. okl/seed.py +55 -0
  61. okl/service.py +137 -0
  62. okl/store.py +432 -0
  63. org_knowledge_layer-0.1.0.dist-info/METADATA +475 -0
  64. org_knowledge_layer-0.1.0.dist-info/RECORD +67 -0
  65. org_knowledge_layer-0.1.0.dist-info/WHEEL +4 -0
  66. org_knowledge_layer-0.1.0.dist-info/entry_points.txt +2 -0
  67. org_knowledge_layer-0.1.0.dist-info/licenses/LICENSE +21 -0
okl/store.py ADDED
@@ -0,0 +1,432 @@
1
+ """Storage layer for the Org Knowledge Layer (OKL).
2
+
3
+ Swappable backend: SQLite by default (a file, or :memory:), Postgres when
4
+ ``OKL_DATABASE_URL`` starts with ``postgres``. Both speak the same node/edge
5
+ schema from the design doc (``the-sixth-surface.md`` Appendix A).
6
+
7
+ The store is the ONLY thing that touches the database. The service and the
8
+ client reach it through this class, never through raw SQL of their own — so
9
+ promoting SQLite -> Postgres is a one-env-var change, no call-site edits.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import time
15
+ import uuid
16
+ from collections.abc import Iterable
17
+ from dataclasses import asdict, dataclass, field
18
+ from typing import Any
19
+
20
+ # Node types and edge relations are closed vocabularies (design doc §3.2).
21
+ NODE_TYPES = {
22
+ "Defect", "Gate", "Rule", "Claim", "Retraction", "Tombstone",
23
+ "Decision", "PriorArt", "Vocabulary", "Entity",
24
+ }
25
+ EDGE_RELS = {
26
+ "CATCHES", "ENCODES", "REFUTES", "RETRACTS", "SUPERSEDES",
27
+ "VERIFIED_ON", "RECURS_IN", "CONTRADICTS", "DEFINED_IN",
28
+ }
29
+ VALID_STATUS = {
30
+ None, "live", "narrowed", "retracted", "open", "resolved", "stale",
31
+ }
32
+ # Subject tags are a controlled vocabulary like NODE_TYPES: grow it by editing this
33
+ # set (a deliberate curation act), not ad hoc — freeform tags sprawl and stop filtering.
34
+ # Stacks mirror the scaffold profiles; the rest are cross-cutting subjects.
35
+ KNOWN_TAGS = {
36
+ "dotnet", "react", "python-rag", "geospatial", # stacks (= scaffold profiles)
37
+ "eval-integrity", "agent-safety", "security", # cross-cutting subjects
38
+ "retrieval-design", "data-quality", "method",
39
+ "messaging", # added 2026-07-21 for the .NET platform canon import (broker/queue/event-driven design)
40
+ }
41
+
42
+
43
+ def split_tags(tags: str | None) -> set[str]:
44
+ """Normalize a comma-separated tag string to a set of lowercase labels."""
45
+ return {t.strip().lower() for t in (tags or "").split(",") if t.strip()}
46
+
47
+
48
+ def _now_ms() -> int:
49
+ return int(time.time() * 1000)
50
+
51
+
52
+ def new_id(prefix: str = "n") -> str:
53
+ return f"{prefix}_{uuid.uuid4().hex[:12]}"
54
+
55
+
56
+ @dataclass
57
+ class Node:
58
+ type: str
59
+ title: str
60
+ scope: str = "repo:unknown" # 'org' or 'repo:<name>'
61
+ repo: str | None = None
62
+ body: str | None = None
63
+ status: str | None = None
64
+ found_by: str | None = None
65
+ verified_at: int | None = None # epoch ms; None = never verified
66
+ ttl_days: int | None = None
67
+ owner: str | None = None
68
+ files: str | None = None # comma-sep path globs this node governs (drift detector)
69
+ symptom: str | None = None # Symptom→Cause→Fix schema (cause lives in body)
70
+ fix: str | None = None
71
+ tags: str | None = None # comma-sep subject labels ("react,security"); orthogonal to scope
72
+ verified_by: str | None = None # evidence trail: the observed check that last stamped verified_at
73
+ id: str = field(default_factory=lambda: new_id("n"))
74
+ created_at: int = field(default_factory=_now_ms)
75
+
76
+ def validate(self) -> None:
77
+ if self.type not in NODE_TYPES:
78
+ raise ValueError(f"unknown node type {self.type!r}; valid: {sorted(NODE_TYPES)}")
79
+ if self.status not in VALID_STATUS:
80
+ raise ValueError(f"unknown status {self.status!r}; valid: {sorted(s for s in VALID_STATUS if s)}")
81
+ if not (self.scope == "org" or self.scope.startswith("repo:")):
82
+ raise ValueError(f"scope must be 'org' or 'repo:<name>', got {self.scope!r}")
83
+ unknown = split_tags(self.tags) - KNOWN_TAGS
84
+ if unknown:
85
+ raise ValueError(f"unknown tag(s) {sorted(unknown)}; the controlled vocabulary is "
86
+ f"{sorted(KNOWN_TAGS)} — extend KNOWN_TAGS deliberately to grow it")
87
+
88
+ def is_stale(self, now_ms: int | None = None) -> bool:
89
+ """A node past its TTL is stale (design doc §3.6 — demote, don't delete)."""
90
+ if self.ttl_days is None:
91
+ return False
92
+ base = self.verified_at if self.verified_at is not None else self.created_at
93
+ now_ms = now_ms if now_ms is not None else _now_ms()
94
+ return now_ms - base > self.ttl_days * 86_400_000
95
+
96
+
97
+ @dataclass
98
+ class Edge:
99
+ src: str
100
+ rel: str
101
+ dst: str
102
+ created_at: int = field(default_factory=_now_ms)
103
+
104
+ def validate(self) -> None:
105
+ if self.rel not in EDGE_RELS:
106
+ raise ValueError(f"unknown edge relation {self.rel!r}; valid: {sorted(EDGE_RELS)}")
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Backend selection
111
+ # ---------------------------------------------------------------------------
112
+ class Store:
113
+ """Facade over whichever backend the environment selects."""
114
+
115
+ def __init__(self, url: str | None = None):
116
+ url = url or os.environ.get("OKL_DATABASE_URL") or "sqlite:///okl.db"
117
+ self.url = url
118
+ if url.startswith("postgres"):
119
+ self._impl: _Backend = _PostgresBackend(url)
120
+ elif url.startswith("sqlite"):
121
+ path = url[len("sqlite:///"):] if url.startswith("sqlite:///") else ":memory:"
122
+ self._impl = _SQLiteBackend(path)
123
+ else:
124
+ raise ValueError(f"unsupported OKL_DATABASE_URL scheme: {url!r}")
125
+ self._impl.init_schema()
126
+
127
+ # -- writes -------------------------------------------------------------
128
+ def add_node(self, node: Node) -> str:
129
+ node.validate()
130
+ self._impl.upsert_node(node)
131
+ return node.id
132
+
133
+ def add_edge(self, edge: Edge) -> None:
134
+ edge.validate()
135
+ self._impl.upsert_edge(edge)
136
+
137
+ # -- reads --------------------------------------------------------------
138
+ def get_node(self, node_id: str) -> Node | None:
139
+ return self._impl.get_node(node_id)
140
+
141
+ def search(self, query: str, scope: str | None = None,
142
+ node_types: Iterable[str] | None = None, limit: int = 25) -> list[Node]:
143
+ return self._impl.search(query, scope, list(node_types) if node_types else None, limit)
144
+
145
+ def neighbors(self, node_id: str, rels: Iterable[str] | None = None) -> list[tuple[Edge, Node]]:
146
+ return self._impl.neighbors(node_id, list(rels) if rels else None)
147
+
148
+ def recurrence_after_arming(self) -> list[dict[str, str]]:
149
+ """The metric the method says it lacks (design doc §3.5 / Appendix A)."""
150
+ return self._impl.recurrence_after_arming()
151
+
152
+ def all_nodes(self) -> list[Node]:
153
+ return self._impl.all_nodes()
154
+
155
+ def close(self) -> None:
156
+ self._impl.close()
157
+
158
+
159
+ class _Backend:
160
+ def init_schema(self) -> None: ...
161
+ def upsert_node(self, node: Node) -> None: ...
162
+ def upsert_edge(self, edge: Edge) -> None: ...
163
+ def get_node(self, node_id: str) -> Node | None: ...
164
+ def search(self, q, scope, node_types, limit) -> list[Node]: ...
165
+ def neighbors(self, node_id, rels) -> list[tuple[Edge, Node]]: ...
166
+ def recurrence_after_arming(self) -> list[dict[str, str]]: ...
167
+ def all_nodes(self) -> list[Node]: ...
168
+ def close(self) -> None: ...
169
+
170
+
171
+ _NODE_COLS = ["id", "type", "scope", "repo", "title", "body", "status",
172
+ "found_by", "verified_at", "ttl_days", "owner", "files",
173
+ "symptom", "fix", "tags", "verified_by", "created_at"]
174
+
175
+
176
+ def _row_to_node(row: dict[str, Any]) -> Node:
177
+ return Node(**{k: row[k] for k in _NODE_COLS if k in row})
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # SQLite backend (default) — FTS5 full-text search
182
+ # ---------------------------------------------------------------------------
183
+ class _SQLiteBackend(_Backend):
184
+ def __init__(self, path: str):
185
+ import sqlite3
186
+ import threading
187
+ self._sqlite3 = sqlite3
188
+ # check_same_thread=False so the FastAPI threadpool can share the conn;
189
+ # a lock serializes access since sqlite3 connections aren't thread-safe.
190
+ self.conn = sqlite3.connect(path, check_same_thread=False)
191
+ self.conn.row_factory = sqlite3.Row
192
+ self._lock = threading.RLock() # reentrant: neighbors() calls get_node()
193
+ try:
194
+ self.conn.execute("PRAGMA journal_mode=WAL")
195
+ except sqlite3.OperationalError:
196
+ pass # :memory: and some FS don't support WAL
197
+ self._has_fts = True
198
+
199
+ def init_schema(self) -> None:
200
+ c = self.conn
201
+ c.execute("""CREATE TABLE IF NOT EXISTS node(
202
+ id TEXT PRIMARY KEY, type TEXT NOT NULL, scope TEXT NOT NULL, repo TEXT,
203
+ title TEXT NOT NULL, body TEXT, status TEXT, found_by TEXT,
204
+ verified_at INTEGER, ttl_days INTEGER, owner TEXT,
205
+ files TEXT, symptom TEXT, fix TEXT, tags TEXT, verified_by TEXT, created_at INTEGER NOT NULL)""")
206
+ # Idempotent migration: add columns introduced after v0.1 to pre-existing DBs.
207
+ existing = {r[1] for r in c.execute("PRAGMA table_info(node)").fetchall()}
208
+ for col in ("files", "symptom", "fix", "tags", "verified_by"):
209
+ if col not in existing:
210
+ c.execute(f"ALTER TABLE node ADD COLUMN {col} TEXT")
211
+ c.execute("""CREATE TABLE IF NOT EXISTS edge(
212
+ src TEXT NOT NULL, rel TEXT NOT NULL, dst TEXT NOT NULL, created_at INTEGER NOT NULL,
213
+ PRIMARY KEY (src, rel, dst))""")
214
+ try:
215
+ c.execute("""CREATE VIRTUAL TABLE IF NOT EXISTS node_fts
216
+ USING fts5(id UNINDEXED, title, body)""")
217
+ except self._sqlite3.OperationalError:
218
+ self._has_fts = False # FTS5 not compiled in — fall back to LIKE
219
+ c.commit()
220
+
221
+ def upsert_node(self, node: Node) -> None:
222
+ with self._lock:
223
+ vals = asdict(node)
224
+ cols = ",".join(_NODE_COLS)
225
+ ph = ",".join("?" for _ in _NODE_COLS)
226
+ self.conn.execute(
227
+ f"INSERT OR REPLACE INTO node({cols}) VALUES({ph})",
228
+ [vals[k] for k in _NODE_COLS],
229
+ )
230
+ if self._has_fts:
231
+ self.conn.execute("DELETE FROM node_fts WHERE id=?", (node.id,))
232
+ self.conn.execute("INSERT INTO node_fts(id,title,body) VALUES(?,?,?)",
233
+ (node.id, node.title or "", node.body or ""))
234
+ self.conn.commit()
235
+
236
+ def upsert_edge(self, edge: Edge) -> None:
237
+ with self._lock:
238
+ self.conn.execute("INSERT OR REPLACE INTO edge(src,rel,dst,created_at) VALUES(?,?,?,?)",
239
+ (edge.src, edge.rel, edge.dst, edge.created_at))
240
+ self.conn.commit()
241
+
242
+ def get_node(self, node_id: str) -> Node | None:
243
+ with self._lock:
244
+ r = self.conn.execute("SELECT * FROM node WHERE id=?", (node_id,)).fetchone()
245
+ return _row_to_node(dict(r)) if r else None
246
+
247
+ def search(self, q, scope, node_types, limit):
248
+ with self._lock:
249
+ params: list[Any] = []
250
+ if self._has_fts and q.strip():
251
+ sql = ("SELECT n.* FROM node_fts f JOIN node n ON n.id=f.id "
252
+ "WHERE node_fts MATCH ?")
253
+ params.append(_fts_query(q))
254
+ else:
255
+ sql = "SELECT * FROM node n WHERE (title LIKE ? OR body LIKE ?)"
256
+ params += [f"%{q}%", f"%{q}%"]
257
+ if scope:
258
+ sql += " AND n.scope=?" if "node_fts" in sql else " AND scope=?"
259
+ params.append(scope)
260
+ if node_types:
261
+ marks = ",".join("?" for _ in node_types)
262
+ sql += f" AND {'n.' if 'node_fts' in sql else ''}type IN ({marks})"
263
+ params += list(node_types)
264
+ # FTS path: order by FTS5 relevance (rank) so the most task-relevant nodes come first,
265
+ # not insertion order. LIKE fallback has no relevance signal — leave unordered.
266
+ if "node_fts" in sql:
267
+ sql += " ORDER BY rank"
268
+ sql += " LIMIT ?"
269
+ params.append(limit)
270
+ rows = self.conn.execute(sql, params).fetchall()
271
+ return [_row_to_node(dict(r)) for r in rows]
272
+
273
+ def neighbors(self, node_id, rels):
274
+ with self._lock:
275
+ sql = ("SELECT e.src,e.rel,e.dst,e.created_at FROM edge e "
276
+ "WHERE e.src=? OR e.dst=?")
277
+ params: list[Any] = [node_id, node_id]
278
+ if rels:
279
+ marks = ",".join("?" for _ in rels)
280
+ sql += f" AND e.rel IN ({marks})"
281
+ params += list(rels)
282
+ out = []
283
+ for r in self.conn.execute(sql, params).fetchall():
284
+ e = Edge(src=r["src"], rel=r["rel"], dst=r["dst"], created_at=r["created_at"])
285
+ other = e.dst if e.src == node_id else e.src
286
+ n = self.get_node(other)
287
+ if n:
288
+ out.append((e, n))
289
+ return out
290
+
291
+ def recurrence_after_arming(self):
292
+ with self._lock:
293
+ sql = """
294
+ SELECT d2.repo AS recurred_in, d1.title AS defect_class, g.title AS gate
295
+ FROM edge rec JOIN node d2 ON rec.src=d2.id AND rec.rel='RECURS_IN'
296
+ JOIN node d1 ON rec.dst=d1.id
297
+ JOIN edge c ON c.rel='CATCHES' AND c.dst=d1.id
298
+ JOIN node g ON c.src=g.id"""
299
+ return [dict(r) for r in self.conn.execute(sql).fetchall()]
300
+
301
+ def all_nodes(self):
302
+ with self._lock:
303
+ return [_row_to_node(dict(r)) for r in self.conn.execute("SELECT * FROM node").fetchall()]
304
+
305
+ def close(self):
306
+ self.conn.close()
307
+
308
+
309
+ def _fts_query(q: str) -> str:
310
+ """Turn free text into a safe FTS5 OR-query of quoted terms."""
311
+ terms = [t for t in "".join(ch if ch.isalnum() else " " for ch in q).split() if t]
312
+ return " OR ".join(f'"{t}"' for t in terms) or '""'
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # Postgres backend — same schema; ranked full-text search mirroring the FTS5 path
317
+ # ---------------------------------------------------------------------------
318
+
319
+ # Weighted tsvector: title (A) outranks body (B). The GIN index in init_schema uses the
320
+ # EXACT same expression — Postgres only uses an expression index when the query matches it.
321
+ _PG_TSV = ("setweight(to_tsvector('english', coalesce(title,'')), 'A') || "
322
+ "setweight(to_tsvector('english', coalesce(body,'')), 'B')")
323
+
324
+
325
+ def _pg_ts_query(q: str) -> str:
326
+ """Free text -> an OR websearch query, mirroring _fts_query's permissive-OR semantics
327
+ (websearch_to_tsquery ANDs terms by default, which would silently tighten recall vs
328
+ the SQLite backend)."""
329
+ terms = [t for t in "".join(ch if ch.isalnum() else " " for ch in q).split() if t]
330
+ return " OR ".join(terms)
331
+
332
+
333
+ class _PostgresBackend(_Backend):
334
+ def __init__(self, url: str):
335
+ try:
336
+ import psycopg
337
+ except ImportError as e: # pragma: no cover - only when pg selected
338
+ raise RuntimeError(
339
+ "Postgres backend needs 'psycopg' — install okl[postgres]"
340
+ ) from e
341
+ self.psycopg = psycopg
342
+ self.conn = psycopg.connect(url, autocommit=True)
343
+
344
+ def init_schema(self):
345
+ with self.conn.cursor() as cur:
346
+ cur.execute("""CREATE TABLE IF NOT EXISTS node(
347
+ id TEXT PRIMARY KEY, type TEXT NOT NULL, scope TEXT NOT NULL, repo TEXT,
348
+ title TEXT NOT NULL, body TEXT, status TEXT, found_by TEXT,
349
+ verified_at BIGINT, ttl_days INTEGER, owner TEXT,
350
+ files TEXT, symptom TEXT, fix TEXT, tags TEXT, verified_by TEXT, created_at BIGINT NOT NULL)""")
351
+ # Idempotent migration for pre-existing tables.
352
+ for col in ("files", "symptom", "fix", "tags", "verified_by"):
353
+ cur.execute(f"ALTER TABLE node ADD COLUMN IF NOT EXISTS {col} TEXT")
354
+ cur.execute("""CREATE TABLE IF NOT EXISTS edge(
355
+ src TEXT NOT NULL, rel TEXT NOT NULL, dst TEXT NOT NULL, created_at BIGINT NOT NULL,
356
+ PRIMARY KEY (src, rel, dst))""")
357
+ cur.execute(f"CREATE INDEX IF NOT EXISTS node_tsv_idx ON node USING GIN (({_PG_TSV}))")
358
+
359
+ def upsert_node(self, node: Node):
360
+ vals = asdict(node)
361
+ cols = ",".join(_NODE_COLS)
362
+ ph = ",".join(f"%({k})s" for k in _NODE_COLS)
363
+ upd = ",".join(f"{k}=EXCLUDED.{k}" for k in _NODE_COLS if k != "id")
364
+ with self.conn.cursor() as cur:
365
+ cur.execute(f"INSERT INTO node({cols}) VALUES({ph}) "
366
+ f"ON CONFLICT(id) DO UPDATE SET {upd}", vals)
367
+
368
+ def upsert_edge(self, edge: Edge):
369
+ with self.conn.cursor() as cur:
370
+ cur.execute("INSERT INTO edge(src,rel,dst,created_at) VALUES(%s,%s,%s,%s) "
371
+ "ON CONFLICT(src,rel,dst) DO NOTHING",
372
+ (edge.src, edge.rel, edge.dst, edge.created_at))
373
+
374
+ def _fetch(self, sql, params):
375
+ with self.conn.cursor() as cur:
376
+ cur.execute(sql, params)
377
+ cols = [d.name for d in cur.description]
378
+ return [dict(zip(cols, row, strict=True)) for row in cur.fetchall()]
379
+
380
+ def get_node(self, node_id):
381
+ rows = self._fetch("SELECT * FROM node WHERE id=%s", (node_id,))
382
+ return _row_to_node(rows[0]) if rows else None
383
+
384
+ def search(self, q, scope, node_types, limit):
385
+ # Ranked full-text when there's a query (parity with the SQLite FTS5/BM25 path);
386
+ # plain ILIKE only as the blank-query fallback. Without ranking, the shared
387
+ # service would silently degrade retrieval below the local-file baseline.
388
+ tsq = _pg_ts_query(q)
389
+ params: list[Any] = []
390
+ if tsq:
391
+ sql = f"SELECT * FROM node WHERE ({_PG_TSV}) @@ websearch_to_tsquery('english', %s)"
392
+ params.append(tsq)
393
+ else:
394
+ sql = "SELECT * FROM node WHERE (title ILIKE %s OR body ILIKE %s)"
395
+ params += [f"%{q}%", f"%{q}%"]
396
+ if scope:
397
+ sql += " AND scope=%s"; params.append(scope)
398
+ if node_types:
399
+ sql += " AND type = ANY(%s)"; params.append(list(node_types))
400
+ if tsq:
401
+ sql += f" ORDER BY ts_rank(({_PG_TSV}), websearch_to_tsquery('english', %s)) DESC"
402
+ params.append(tsq)
403
+ sql += " LIMIT %s"; params.append(limit)
404
+ return [_row_to_node(r) for r in self._fetch(sql, params)]
405
+
406
+ def neighbors(self, node_id, rels):
407
+ sql = "SELECT src,rel,dst,created_at FROM edge WHERE (src=%s OR dst=%s)"
408
+ params: list[Any] = [node_id, node_id]
409
+ if rels:
410
+ sql += " AND rel = ANY(%s)"; params.append(list(rels))
411
+ out = []
412
+ for r in self._fetch(sql, params):
413
+ e = Edge(**r)
414
+ other = e.dst if e.src == node_id else e.src
415
+ n = self.get_node(other)
416
+ if n:
417
+ out.append((e, n))
418
+ return out
419
+
420
+ def recurrence_after_arming(self):
421
+ return self._fetch("""
422
+ SELECT d2.repo AS recurred_in, d1.title AS defect_class, g.title AS gate
423
+ FROM edge rec JOIN node d2 ON rec.src=d2.id AND rec.rel='RECURS_IN'
424
+ JOIN node d1 ON rec.dst=d1.id
425
+ JOIN edge c ON c.rel='CATCHES' AND c.dst=d1.id
426
+ JOIN node g ON c.src=g.id""", ())
427
+
428
+ def all_nodes(self):
429
+ return [_row_to_node(r) for r in self._fetch("SELECT * FROM node", ())]
430
+
431
+ def close(self):
432
+ self.conn.close()