pycode-kg 0.16.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 (63) hide show
  1. pycode_kg/.DS_Store +0 -0
  2. pycode_kg/__init__.py +91 -0
  3. pycode_kg/__main__.py +11 -0
  4. pycode_kg/analysis/__init__.py +15 -0
  5. pycode_kg/analysis/bridge.py +108 -0
  6. pycode_kg/analysis/centrality.py +412 -0
  7. pycode_kg/analysis/framework_detector.py +103 -0
  8. pycode_kg/analysis/hybrid_rank.py +53 -0
  9. pycode_kg/app.py +1335 -0
  10. pycode_kg/architecture.py +624 -0
  11. pycode_kg/build_pycodekg_lancedb.py +15 -0
  12. pycode_kg/build_pycodekg_sqlite.py +15 -0
  13. pycode_kg/cli/__init__.py +29 -0
  14. pycode_kg/cli/cmd_analyze.py +96 -0
  15. pycode_kg/cli/cmd_architecture.py +150 -0
  16. pycode_kg/cli/cmd_bridges.py +26 -0
  17. pycode_kg/cli/cmd_build.py +154 -0
  18. pycode_kg/cli/cmd_build_full.py +242 -0
  19. pycode_kg/cli/cmd_centrality.py +131 -0
  20. pycode_kg/cli/cmd_explain.py +180 -0
  21. pycode_kg/cli/cmd_framework_nodes.py +18 -0
  22. pycode_kg/cli/cmd_hooks.py +137 -0
  23. pycode_kg/cli/cmd_init.py +312 -0
  24. pycode_kg/cli/cmd_mcp.py +71 -0
  25. pycode_kg/cli/cmd_model.py +53 -0
  26. pycode_kg/cli/cmd_query.py +211 -0
  27. pycode_kg/cli/cmd_snapshot.py +421 -0
  28. pycode_kg/cli/cmd_viz.py +180 -0
  29. pycode_kg/cli/main.py +23 -0
  30. pycode_kg/cli/options.py +63 -0
  31. pycode_kg/config.py +78 -0
  32. pycode_kg/graph.py +125 -0
  33. pycode_kg/index.py +542 -0
  34. pycode_kg/kg.py +220 -0
  35. pycode_kg/layout3d.py +470 -0
  36. pycode_kg/mcp/bridge_tools.py +19 -0
  37. pycode_kg/mcp/framework_tools.py +18 -0
  38. pycode_kg/mcp_server.py +1965 -0
  39. pycode_kg/module/__init__.py +83 -0
  40. pycode_kg/module/base.py +720 -0
  41. pycode_kg/module/extractor.py +276 -0
  42. pycode_kg/module/types.py +532 -0
  43. pycode_kg/pycodekg.py +543 -0
  44. pycode_kg/pycodekg_query.py +1 -0
  45. pycode_kg/pycodekg_snippet_packer.py +1 -0
  46. pycode_kg/pycodekg_thorough_analysis.py +2751 -0
  47. pycode_kg/pycodekg_viz.py +1 -0
  48. pycode_kg/pycodekg_viz3d.py +1 -0
  49. pycode_kg/ranking/__init__.py +1 -0
  50. pycode_kg/ranking/cli_rank.py +92 -0
  51. pycode_kg/ranking/coderank.py +555 -0
  52. pycode_kg/snapshots.py +612 -0
  53. pycode_kg/sql/004_add_centrality_table.sql +12 -0
  54. pycode_kg/store.py +766 -0
  55. pycode_kg/utils.py +39 -0
  56. pycode_kg/visitor.py +413 -0
  57. pycode_kg/viz3d.py +1353 -0
  58. pycode_kg/viz3d_timeline.py +364 -0
  59. pycode_kg-0.16.0.dist-info/METADATA +305 -0
  60. pycode_kg-0.16.0.dist-info/RECORD +63 -0
  61. pycode_kg-0.16.0.dist-info/WHEEL +4 -0
  62. pycode_kg-0.16.0.dist-info/entry_points.txt +19 -0
  63. pycode_kg-0.16.0.dist-info/licenses/LICENSE +94 -0
pycode_kg/index.py ADDED
@@ -0,0 +1,542 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ index.py
4
+
5
+ SemanticIndex — LanceDB vector index for the Code Knowledge Graph.
6
+
7
+ Derived from SQLite; disposable and rebuildable at any time.
8
+ SQLite (GraphStore) remains the authoritative source of truth.
9
+
10
+ Author: Eric G. Suchanek, PhD
11
+ Last Revision: 2026-03-13 22:53:21
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import os
18
+ import re
19
+ from collections.abc import Sequence
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import TYPE_CHECKING
23
+
24
+ import numpy as np
25
+
26
+ from pycode_kg.pycodekg import DEFAULT_MODEL
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Local model cache
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ def _local_model_path(model_name: str) -> Path:
34
+ """Return the local cache path for *model_name*.
35
+
36
+ Defaults to ``.pycodekg/models/<model>`` under the current working directory
37
+ so the cache lives alongside the rest of the PyCodeKG artefacts.
38
+ Override via the ``PYCODEKG_MODEL_DIR`` environment variable.
39
+
40
+ Slashes in the model name (e.g. ``org/model``) are replaced with ``--``
41
+ so the path is always a single directory level under the cache root.
42
+
43
+ :param model_name: HuggingFace model identifier or short name.
44
+ :return: Absolute :class:`~pathlib.Path` to the cached model directory.
45
+ """
46
+ import os # pylint: disable=import-outside-toplevel
47
+
48
+ default = str(Path.cwd() / ".pycodekg" / "models")
49
+ cache_root = Path(os.environ.get("PYCODEKG_MODEL_DIR", default))
50
+ safe_name = model_name.replace("/", "--")
51
+ return cache_root / safe_name
52
+
53
+
54
+ if TYPE_CHECKING:
55
+ from pycode_kg.store import GraphStore
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Logging / progress suppression
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ def suppress_ingestion_logging() -> None:
63
+ """Suppress verbose progress output during model loading and ingestion.
64
+
65
+ Suppresses Python logging, transformers/HuggingFace verbosity, and all
66
+ tqdm progress bars (including sentence_transformers' internal "Batches:"
67
+ bars) for the lifetime of the process.
68
+ """
69
+ for name in (
70
+ "sentence_transformers",
71
+ "transformers",
72
+ "huggingface_hub",
73
+ "lancedb",
74
+ "pylance",
75
+ ):
76
+ logging.getLogger(name).setLevel(logging.WARNING)
77
+
78
+ try:
79
+ import transformers # pylint: disable=import-outside-toplevel
80
+
81
+ transformers.logging.set_verbosity_error()
82
+ except (ImportError, AttributeError):
83
+ pass
84
+
85
+ # tqdm checks TQDM_DISABLE at instantiation time, so setting it here
86
+ # silences all subclasses regardless of when they were imported.
87
+ os.environ["TQDM_DISABLE"] = "1"
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Embedder interface (pluggable)
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ class Embedder:
96
+ """
97
+ Abstract embedding backend.
98
+
99
+ Subclass and implement :meth:`embed_texts` to plug in any model.
100
+
101
+ :param dim: Embedding dimension (must be set by subclass ``__init__``).
102
+ """
103
+
104
+ dim: int
105
+
106
+ def embed_texts(self, texts: list[str]) -> list[list[float]]:
107
+ """
108
+ Embed a list of strings.
109
+
110
+ :param texts: Input strings.
111
+ :return: List of float32 vectors, one per input.
112
+ """
113
+ raise NotImplementedError
114
+
115
+ def embed_query(self, query: str) -> list[float]:
116
+ """
117
+ Embed a single query string.
118
+
119
+ Default implementation calls :meth:`embed_texts` with a one-element list.
120
+
121
+ :param query: Query string.
122
+ :return: Float32 vector.
123
+ """
124
+ return self.embed_texts([query])[0]
125
+
126
+
127
+ class SentenceTransformerEmbedder(Embedder):
128
+ """
129
+ Local embedding via ``sentence-transformers``.
130
+
131
+ :param model_name: HuggingFace model name or local path.
132
+ Defaults to :data:`~pycode_kg.pycodekg.DEFAULT_MODEL`.
133
+ """
134
+
135
+ def __init__(self, model_name: str = DEFAULT_MODEL) -> None:
136
+ """Load the sentence-transformer model.
137
+
138
+ :param model_name: HuggingFace model name or local path.
139
+ """
140
+ import os # pylint: disable=import-outside-toplevel
141
+
142
+ from sentence_transformers import ( # pylint: disable=import-outside-toplevel
143
+ SentenceTransformer,
144
+ )
145
+ from transformers import logging as hf_logging # pylint: disable=import-outside-toplevel
146
+
147
+ hf_logging.set_verbosity_error()
148
+
149
+ local_path = _local_model_path(model_name)
150
+ _prev_tqdm = os.environ.get("TQDM_DISABLE")
151
+ os.environ["TQDM_DISABLE"] = "1"
152
+ try:
153
+ if local_path.exists():
154
+ self.model = SentenceTransformer(str(local_path), trust_remote_code=True)
155
+ else:
156
+ try:
157
+ self.model = SentenceTransformer(
158
+ model_name, local_files_only=True, trust_remote_code=True
159
+ )
160
+ except OSError:
161
+ self.model = SentenceTransformer(model_name, trust_remote_code=True)
162
+ finally:
163
+ if _prev_tqdm is None:
164
+ os.environ.pop("TQDM_DISABLE", None)
165
+ else:
166
+ os.environ["TQDM_DISABLE"] = _prev_tqdm
167
+ self.model_name = model_name
168
+ self.dim: int = self.model.get_embedding_dimension() or 384
169
+ # Detect task-prompt support (e.g. nomic-embed-text-v1.5).
170
+ # sentence-transformers exposes model.prompts as a dict of name→prefix.
171
+ _prompts: dict = getattr(self.model, "prompts", {}) or {}
172
+ self._query_prompt: str | None = "search_query" if "search_query" in _prompts else None
173
+ self._doc_prompt: str | None = "search_document" if "search_document" in _prompts else None
174
+
175
+ def embed_texts(self, texts: list[str]) -> list[list[float]]:
176
+ """Embed a list of strings into float32 vectors.
177
+
178
+ Uses ``search_document`` task prompt when the model supports it
179
+ (e.g. ``nomic-ai/nomic-embed-text-v1.5``).
180
+
181
+ :param texts: Input strings to embed.
182
+ :return: List of float32 vectors, one per input string.
183
+ """
184
+ kwargs: dict = {"normalize_embeddings": True, "show_progress_bar": False}
185
+ if self._doc_prompt:
186
+ kwargs["prompt_name"] = self._doc_prompt
187
+ vecs = self.model.encode(texts, **kwargs)
188
+ return [np.asarray(v, dtype="float32").tolist() for v in vecs]
189
+
190
+ def embed_query(self, query: str) -> list[float]:
191
+ """Embed a single query string into a float32 vector.
192
+
193
+ Uses ``search_query`` task prompt when the model supports it
194
+ (e.g. ``nomic-ai/nomic-embed-text-v1.5``).
195
+
196
+ :param query: Query string to embed.
197
+ :return: Float32 vector representation of the query.
198
+ """
199
+ kwargs: dict = {"normalize_embeddings": True}
200
+ if self._query_prompt:
201
+ kwargs["prompt_name"] = self._query_prompt
202
+ vec = self.model.encode([query], **kwargs)[0]
203
+ return np.asarray(vec, dtype="float32").tolist()
204
+
205
+ def __repr__(self) -> str:
206
+ """Return a developer-readable representation of this embedder.
207
+
208
+ :return: String of the form ``SentenceTransformerEmbedder(model=..., dim=...)``.
209
+ """
210
+ return f"SentenceTransformerEmbedder(model={self.model_name!r}, dim={self.dim})"
211
+
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # Seed hit returned by SemanticIndex.search()
215
+ # ---------------------------------------------------------------------------
216
+
217
+
218
+ @dataclass
219
+ class SeedHit:
220
+ """
221
+ A single result from a semantic vector search.
222
+
223
+ :param id: Node ID.
224
+ :param kind: Node kind (``module``, ``class``, ``function``, ``method``).
225
+ :param name: Short name.
226
+ :param qualname: Qualified name.
227
+ :param module_path: Repo-relative module path.
228
+ :param distance: Vector distance (lower = more similar).
229
+ :param rank: Zero-based rank in the result list.
230
+ """
231
+
232
+ id: str
233
+ kind: str
234
+ name: str
235
+ qualname: str
236
+ module_path: str
237
+ distance: float
238
+ rank: int
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # SemanticIndex
243
+ # ---------------------------------------------------------------------------
244
+
245
+ _DEFAULT_TABLE = "pycodekg_nodes"
246
+ _DEFAULT_KINDS = ("module", "class", "function", "method")
247
+
248
+
249
+ class SemanticIndex:
250
+ """
251
+ LanceDB-backed semantic vector index for the Code Knowledge Graph.
252
+
253
+ Reads nodes from a :class:`~pycode_kg.store.GraphStore` (via its SQLite
254
+ database), embeds them, and stores the vectors in LanceDB. The index
255
+ is **derived and disposable** — it can be rebuilt from SQLite at any
256
+ time without data loss.
257
+
258
+ Example::
259
+
260
+ embedder = SentenceTransformerEmbedder()
261
+ idx = SemanticIndex("./lancedb", embedder=embedder)
262
+ idx.build(store, wipe=True)
263
+
264
+ hits = idx.search("database connection setup", k=8)
265
+ for h in hits:
266
+ print(h.id, h.distance)
267
+
268
+ :param lancedb_dir: Directory for the LanceDB database.
269
+ :param embedder: Embedding backend. Defaults to
270
+ :class:`SentenceTransformerEmbedder` with
271
+ :data:`~pycode_kg.pycodekg.DEFAULT_MODEL`.
272
+ :param table: LanceDB table name. Defaults to ``"pycodekg_nodes"``.
273
+ :param index_kinds: Node kinds to embed.
274
+ """
275
+
276
+ def __init__(
277
+ self,
278
+ lancedb_dir: str | Path,
279
+ *,
280
+ embedder: Embedder | None = None,
281
+ table: str = _DEFAULT_TABLE,
282
+ index_kinds: Sequence[str] = _DEFAULT_KINDS,
283
+ ) -> None:
284
+ """Initialise the semantic index.
285
+
286
+ :param lancedb_dir: Directory for the LanceDB database.
287
+ :param embedder: Embedding backend. Defaults to :class:`SentenceTransformerEmbedder`.
288
+ :param table: LanceDB table name. Defaults to ``"pycodekg_nodes"``.
289
+ :param index_kinds: Node kinds to include in the index.
290
+ """
291
+ self.lancedb_dir = Path(lancedb_dir)
292
+ self.embedder: Embedder = embedder or SentenceTransformerEmbedder()
293
+ self.table_name = table
294
+ self.index_kinds = tuple(index_kinds)
295
+ self._tbl = None # lazy LanceDB table handle
296
+
297
+ # ------------------------------------------------------------------
298
+ # Build
299
+ # ------------------------------------------------------------------
300
+
301
+ def build(
302
+ self,
303
+ store: GraphStore,
304
+ *,
305
+ wipe: bool = False,
306
+ batch_size: int = 256,
307
+ quiet: bool = True,
308
+ ) -> dict:
309
+ """
310
+ Build (or rebuild) the vector index from *store*.
311
+
312
+ :param store: Authoritative :class:`~pycode_kg.store.GraphStore`.
313
+ :param wipe: If ``True``, delete all existing vectors first.
314
+ :param batch_size: Number of nodes to embed per batch.
315
+ :param quiet: If ``True`` (default), suppress progress output from LanceDB and
316
+ sentence-transformers during ingestion.
317
+ :return: Stats dict with ``indexed_rows``, ``dim``, ``table``,
318
+ ``lancedb_dir``, ``kinds``.
319
+ """
320
+ if quiet:
321
+ suppress_ingestion_logging()
322
+
323
+ nodes = self._read_nodes(store)
324
+ tbl = self._open_table(wipe=wipe)
325
+
326
+ indexed = 0
327
+ for i in range(0, len(nodes), batch_size):
328
+ chunk = nodes[i : i + batch_size]
329
+ texts = [_build_index_text(n) for n in chunk]
330
+ vecs = self.embedder.embed_texts(texts)
331
+
332
+ # upsert: delete existing IDs then add fresh rows
333
+ ids = [n["id"] for n in chunk]
334
+ if ids:
335
+ pred = " OR ".join([f"id = '{_escape(nid)}'" for nid in ids])
336
+ tbl.delete(pred)
337
+
338
+ rows = [
339
+ {
340
+ "id": n["id"],
341
+ "kind": n["kind"],
342
+ "name": n["name"],
343
+ "qualname": n["qualname"] or "",
344
+ "module_path": n["module_path"] or "",
345
+ "text": text,
346
+ "vector": vec,
347
+ }
348
+ for n, text, vec in zip(chunk, texts, vecs)
349
+ ]
350
+ tbl.add(rows)
351
+ indexed += len(rows)
352
+
353
+ self._tbl = tbl
354
+ return {
355
+ "indexed_rows": indexed,
356
+ "dim": self.embedder.dim,
357
+ "table": self.table_name,
358
+ "lancedb_dir": str(self.lancedb_dir),
359
+ "kinds": list(self.index_kinds),
360
+ }
361
+
362
+ # ------------------------------------------------------------------
363
+ # Search
364
+ # ------------------------------------------------------------------
365
+
366
+ def search(self, query: str, k: int = 8) -> list[SeedHit]:
367
+ """
368
+ Semantic vector search.
369
+
370
+ :param query: Natural-language query string.
371
+ :param k: Number of results to return.
372
+ :return: List of :class:`SeedHit` ordered by ascending distance.
373
+ """
374
+ tbl = self._get_table()
375
+ qvec = self.embedder.embed_query(query)
376
+ raw = tbl.search(qvec).limit(k).to_list()
377
+
378
+ hits: list[SeedHit] = []
379
+ for rank, row in enumerate(raw):
380
+ dist = _extract_distance(row, rank)
381
+ hits.append(
382
+ SeedHit(
383
+ id=row["id"],
384
+ kind=row.get("kind", ""),
385
+ name=row.get("name", ""),
386
+ qualname=row.get("qualname", ""),
387
+ module_path=row.get("module_path", ""),
388
+ distance=dist,
389
+ rank=rank,
390
+ )
391
+ )
392
+ return hits
393
+
394
+ # ------------------------------------------------------------------
395
+ # Internal helpers
396
+ # ------------------------------------------------------------------
397
+
398
+ def _read_nodes(self, store: GraphStore) -> list[dict]:
399
+ """Read indexable nodes from the store filtered by ``index_kinds``.
400
+
401
+ :param store: Authoritative :class:`~pycode_kg.store.GraphStore` to query.
402
+ :return: List of node dicts for kinds in :attr:`index_kinds`.
403
+ """
404
+ return store.query_nodes(kinds=list(self.index_kinds))
405
+
406
+ def _open_table(self, *, wipe: bool = False):
407
+ """Open the LanceDB table, creating it with the correct schema if absent.
408
+
409
+ :param wipe: If ``True``, delete all existing rows after opening.
410
+ :return: LanceDB table handle.
411
+ """
412
+ import lancedb # pylint: disable=import-outside-toplevel
413
+
414
+ self.lancedb_dir.mkdir(parents=True, exist_ok=True)
415
+ db = lancedb.connect(str(self.lancedb_dir)) # type: ignore[attr-defined]
416
+
417
+ if self.table_name in db.list_tables().tables:
418
+ if wipe:
419
+ db.drop_table(self.table_name)
420
+ else:
421
+ try:
422
+ return db.open_table(self.table_name)
423
+ except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught
424
+ # Table directory exists on disk but is corrupt/incomplete
425
+ # (e.g. from a previously interrupted build — empty _versions
426
+ # or data directories). Drop and recreate it cleanly.
427
+ logging.getLogger(__name__).warning(
428
+ "LanceDB table %r appears corrupt (%s); dropping and recreating.",
429
+ self.table_name,
430
+ exc,
431
+ )
432
+ db.drop_table(self.table_name)
433
+
434
+ # Create with a dummy row to establish schema, then remove it
435
+ dummy = {
436
+ "id": "__dummy__",
437
+ "kind": "dummy",
438
+ "name": "__dummy__",
439
+ "qualname": "",
440
+ "module_path": "",
441
+ "text": "__dummy__",
442
+ "vector": np.zeros((self.embedder.dim,), dtype="float32").tolist(),
443
+ }
444
+ tbl = db.create_table(self.table_name, data=[dummy])
445
+ tbl.delete("id = '__dummy__'")
446
+ return tbl
447
+
448
+ def _get_table(self):
449
+ """Return the cached LanceDB table handle, opening it if not yet loaded.
450
+
451
+ :return: LanceDB table handle.
452
+ """
453
+ if self._tbl is None:
454
+ import lancedb # pylint: disable=import-outside-toplevel
455
+
456
+ db = lancedb.connect(str(self.lancedb_dir)) # type: ignore[attr-defined]
457
+ self._tbl = db.open_table(self.table_name)
458
+ return self._tbl
459
+
460
+ def __repr__(self) -> str:
461
+ """Return a developer-readable representation of this SemanticIndex.
462
+
463
+ :return: String including lancedb_dir, table name, and embedder details.
464
+ """
465
+ return (
466
+ f"SemanticIndex(lancedb_dir={self.lancedb_dir!r}, "
467
+ f"table={self.table_name!r}, embedder={self.embedder!r})"
468
+ )
469
+
470
+
471
+ # ---------------------------------------------------------------------------
472
+ # Internal utilities
473
+ # ---------------------------------------------------------------------------
474
+
475
+
476
+ def _build_index_text(n: dict) -> str:
477
+ """Build the canonical text document used for embedding a node.
478
+
479
+ **Format version 2** — includes a KEYWORDS section of de-duplicated word
480
+ tokens extracted from the name, qualname, and module path. This improves
481
+ recall for abstract queries (for example "error handling strategy",
482
+ "logging approach", "entry points", and "configuration") that do not
483
+ match on names alone. Rebuilding the LanceDB index is required after this
484
+ change (``pycodekg build-lancedb --wipe``).
485
+
486
+ Retrieval quality depends heavily on vocabulary in node docstrings.
487
+ Include meaningful terms for behavior, parameters, fallback semantics,
488
+ and operational concerns so both semantic search and lexical reranking
489
+ can surface the right nodes.
490
+
491
+ :param n: Node dict with keys ``kind``, ``name``, ``qualname``, ``module_path``,
492
+ ``lineno``, and optionally ``docstring``.
493
+ :return: Newline-joined string suitable for embedding.
494
+ """
495
+ parts = [f"KIND: {n['kind']}", f"NAME: {n['name']}"]
496
+ if n.get("qualname"):
497
+ parts.append(f"QUALNAME: {n['qualname']}")
498
+ if n.get("module_path"):
499
+ parts.append(f"MODULE: {n['module_path']}")
500
+ if n.get("lineno") is not None:
501
+ parts.append(f"LINE: {n['lineno']}")
502
+ if n.get("docstring"):
503
+ parts.append("DOCSTRING:\n" + n["docstring"].strip())
504
+
505
+ # Augment with word-token keywords from name/qualname/module for abstract query matching.
506
+ # Splitting snake_case and path components gives the model more signal when the docstring
507
+ # is absent or brief (e.g. "error" + "handler" from "_error_handler" matches "error handling").
508
+ raw = " ".join(filter(None, [n.get("name"), n.get("qualname"), n.get("module_path")]))
509
+ tokens = [w.lower() for w in re.findall(r"[a-zA-Z]+", raw) if len(w) > 2]
510
+ seen_in_doc = set(re.findall(r"[a-zA-Z]+", (n.get("docstring") or "").lower()))
511
+ extra = [t for t in dict.fromkeys(tokens) if t not in seen_in_doc]
512
+ if extra:
513
+ parts.append("KEYWORDS: " + " ".join(extra))
514
+
515
+ return "\n".join(parts)
516
+
517
+
518
+ def _extract_distance(row: dict, fallback_rank: int) -> float:
519
+ """Extract a distance value from a LanceDB result row.
520
+
521
+ Tries ``_distance``, then ``distance``, then inverts ``score``.
522
+ Falls back to the row's rank if no distance field is found.
523
+
524
+ :param row: Raw result dict from LanceDB.
525
+ :param fallback_rank: Zero-based rank to use when no distance field is present.
526
+ :return: Float distance value (lower = more similar).
527
+ """
528
+ for key in ("_distance", "distance"):
529
+ if key in row and row[key] is not None:
530
+ return float(row[key])
531
+ if "score" in row and row["score"] is not None:
532
+ return 1.0 / (1.0 + float(row["score"]))
533
+ return float(fallback_rank)
534
+
535
+
536
+ def _escape(s: str) -> str:
537
+ """Escape single quotes in a string for use in LanceDB delete predicates.
538
+
539
+ :param s: String to escape.
540
+ :return: String with single quotes doubled.
541
+ """
542
+ return s.replace("'", "''")