contextual-engine 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.
- contextual/__init__.py +18 -0
- contextual/__main__.py +11 -0
- contextual/cli.py +339 -0
- contextual/cli_docs.py +685 -0
- contextual/config.py +7 -0
- contextual/core/__init__.py +11 -0
- contextual/core/errors.py +470 -0
- contextual/core/models.py +590 -0
- contextual/docs/__init__.py +66 -0
- contextual/docs/chunker.py +550 -0
- contextual/docs/pipeline.py +513 -0
- contextual/docs/retrieval.py +654 -0
- contextual/docs/watcher.py +265 -0
- contextual/embedding/__init__.py +87 -0
- contextual/embedding/cache.py +455 -0
- contextual/embedding/embedder.py +414 -0
- contextual/embedding/helpers.py +252 -0
- contextual/git/__init__.py +22 -0
- contextual/git/blame.py +334 -0
- contextual/indexing/__init__.py +20 -0
- contextual/indexing/bug_sweep.py +119 -0
- contextual/indexing/chunker.py +691 -0
- contextual/indexing/embedder.py +271 -0
- contextual/indexing/file_watcher.py +154 -0
- contextual/indexing/incremental.py +260 -0
- contextual/indexing/index_writer.py +442 -0
- contextual/indexing/pipeline.py +438 -0
- contextual/indexing/processor.py +436 -0
- contextual/indexing/queries/readme.md +22 -0
- contextual/indexing/symbol_extractor.py +426 -0
- contextual/indexing/tokenizer.py +203 -0
- contextual/integrations/__init__.py +10 -0
- contextual/mcp/__init__.py +15 -0
- contextual/mcp/__main__.py +24 -0
- contextual/mcp/docs_tools.py +286 -0
- contextual/mcp/server.py +118 -0
- contextual/mcp/tools.py +443 -0
- contextual/observability/__init__.py +21 -0
- contextual/observability/logging.py +115 -0
- contextual/py.typed +0 -0
- contextual/retrieval/__init__.py +24 -0
- contextual/retrieval/context_assembler.py +372 -0
- contextual/retrieval/ranker.py +193 -0
- contextual/retrieval/search.py +548 -0
- contextual/security/__init__.py +52 -0
- contextual/security/paths.py +347 -0
- contextual/security/sanitize.py +349 -0
- contextual/security/workspace.py +348 -0
- contextual/storage/__init__.py +36 -0
- contextual/storage/fts_manager.py +273 -0
- contextual/storage/migration_v2.py +289 -0
- contextual/storage/migrations.py +316 -0
- contextual/storage/schema.py +210 -0
- contextual/storage/sqlite_pool.py +468 -0
- contextual/storage/vec0_manager.py +421 -0
- contextual_engine-0.1.0.dist-info/METADATA +297 -0
- contextual_engine-0.1.0.dist-info/RECORD +60 -0
- contextual_engine-0.1.0.dist-info/WHEEL +4 -0
- contextual_engine-0.1.0.dist-info/entry_points.txt +2 -0
- contextual_engine-0.1.0.dist-info/licenses/LICENSE +111 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
"""Docs retrieval layer for Contextual Phase 2.
|
|
2
|
+
|
|
3
|
+
Three public functions consumed directly by the MCP tools:
|
|
4
|
+
|
|
5
|
+
docs_search() — hybrid BM25 + vector search over doc chunks
|
|
6
|
+
docs_get_section() — fetch exact section by 5-tier heading path resolution
|
|
7
|
+
docs_list_files() — enumerate indexed doc files with optional heading tree
|
|
8
|
+
|
|
9
|
+
All functions accept a raw ``sqlite3.Connection`` and an ``Embedder`` so they
|
|
10
|
+
are fully testable without a live MCP context.
|
|
11
|
+
|
|
12
|
+
FTS5 BM25 column weights for docs:
|
|
13
|
+
(heading_path ×3, content ×1)
|
|
14
|
+
Technical heading terms are rare/high-IDF → boost them hard.
|
|
15
|
+
|
|
16
|
+
RRF k = 60 (standard), bm25_weight = 0.4, dense_weight = 0.6
|
|
17
|
+
(prose benefits more from semantic similarity than exact term match).
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import fnmatch
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from sqlite3 import Connection
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import structlog
|
|
30
|
+
from rapidfuzz import fuzz
|
|
31
|
+
|
|
32
|
+
from contextual.embedding.embedder import CodeEmbedder
|
|
33
|
+
|
|
34
|
+
logger = structlog.get_logger(__name__)
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# RRF / scoring constants
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
RRF_K: int = 60
|
|
40
|
+
BM25_WEIGHT: float = 0.4
|
|
41
|
+
DENSE_WEIGHT: float = 0.6
|
|
42
|
+
DOC_SOURCE_TYPES: tuple[str, ...] = ("docs", "docs_code")
|
|
43
|
+
|
|
44
|
+
# Fuzzy heading match threshold (0-100)
|
|
45
|
+
FUZZY_MATCH_THRESHOLD: int = 72
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Result data models
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
@dataclass(slots=True)
|
|
52
|
+
class DocSearchHit:
|
|
53
|
+
"""A single search result from docs_search."""
|
|
54
|
+
chunk_id: int
|
|
55
|
+
file_path: str
|
|
56
|
+
content: str
|
|
57
|
+
heading_path: str
|
|
58
|
+
heading_level: int
|
|
59
|
+
chunk_type: str # "docs" | "docs_code"
|
|
60
|
+
start_line: int
|
|
61
|
+
end_line: int
|
|
62
|
+
score: float
|
|
63
|
+
bm25_rank: int | None = None
|
|
64
|
+
vector_rank: int | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(slots=True)
|
|
68
|
+
class DocSection:
|
|
69
|
+
"""A resolved document section from docs_get_section."""
|
|
70
|
+
file_path: str
|
|
71
|
+
heading_path: str
|
|
72
|
+
heading_level: int
|
|
73
|
+
content: str
|
|
74
|
+
start_line: int
|
|
75
|
+
end_line: int
|
|
76
|
+
subsections: list["DocSection"] = field(default_factory=list)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(slots=True)
|
|
80
|
+
class DocFileInfo:
|
|
81
|
+
"""Metadata about an indexed doc file from docs_list_files."""
|
|
82
|
+
file_path: str
|
|
83
|
+
chunk_count: int
|
|
84
|
+
headings: list[str] = field(default_factory=list) # only when include_headings=True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Source filter helpers
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def _build_source_filter_clause(
|
|
92
|
+
source_filter: dict[str, Any] | None,
|
|
93
|
+
) -> tuple[str, list[Any]]:
|
|
94
|
+
"""Convert source_filter dict into a SQL WHERE fragment + params.
|
|
95
|
+
|
|
96
|
+
Supported keys:
|
|
97
|
+
repo list[str] — match file_path prefix
|
|
98
|
+
file list[str] — exact or glob file_path match
|
|
99
|
+
heading str — SQL LIKE pattern on heading_path
|
|
100
|
+
source_type list[str] — restrict to 'docs', 'docs_code', or both
|
|
101
|
+
"""
|
|
102
|
+
clauses: list[str] = []
|
|
103
|
+
params: list[Any] = []
|
|
104
|
+
|
|
105
|
+
# Always restrict to doc source types
|
|
106
|
+
clauses.append("source_type IN ('docs','docs_code')")
|
|
107
|
+
|
|
108
|
+
if not source_filter:
|
|
109
|
+
return " AND ".join(clauses), params
|
|
110
|
+
|
|
111
|
+
if st := source_filter.get("source_type"):
|
|
112
|
+
safe = [s for s in st if s in DOC_SOURCE_TYPES]
|
|
113
|
+
if safe:
|
|
114
|
+
ph = ",".join("?" * len(safe))
|
|
115
|
+
clauses[-1] = f"source_type IN ({ph})" # override default
|
|
116
|
+
params.extend(safe)
|
|
117
|
+
|
|
118
|
+
if repos := source_filter.get("repo"):
|
|
119
|
+
repo_clauses = " OR ".join("file_path LIKE ?" for _ in repos)
|
|
120
|
+
clauses.append(f"({repo_clauses})")
|
|
121
|
+
params.extend(f"{r}/%" for r in repos)
|
|
122
|
+
|
|
123
|
+
if files := source_filter.get("file"):
|
|
124
|
+
file_clauses = " OR ".join("file_path = ?" for _ in files)
|
|
125
|
+
clauses.append(f"({file_clauses})")
|
|
126
|
+
params.extend(files)
|
|
127
|
+
|
|
128
|
+
if heading := source_filter.get("heading"):
|
|
129
|
+
clauses.append("heading_path LIKE ?")
|
|
130
|
+
params.append(f"%{heading}%")
|
|
131
|
+
|
|
132
|
+
return " AND ".join(clauses), params
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# BM25 search (FTS5)
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def _bm25_docs_search(
|
|
140
|
+
conn: Connection,
|
|
141
|
+
query: str,
|
|
142
|
+
k: int,
|
|
143
|
+
filter_clause: str,
|
|
144
|
+
filter_params: list[Any],
|
|
145
|
+
) -> list[tuple[int, float]]:
|
|
146
|
+
"""Run FTS5 BM25 search over docs chunks.
|
|
147
|
+
|
|
148
|
+
Returns list of (chunk_id, bm25_score) sorted by score descending.
|
|
149
|
+
BM25 scores from FTS5 are negative — we negate to get positive rank values.
|
|
150
|
+
Column weights: heading_path ×3, content ×1.
|
|
151
|
+
"""
|
|
152
|
+
# Sanitise query: strip FTS5 special chars that cause parse errors
|
|
153
|
+
safe_q = re.sub(r'["\'\(\)\*\:\^]', " ", query).strip()
|
|
154
|
+
if not safe_q:
|
|
155
|
+
return []
|
|
156
|
+
|
|
157
|
+
sql = f"""
|
|
158
|
+
SELECT c.id, -bm25(fts_chunks) AS score
|
|
159
|
+
FROM fts_chunks
|
|
160
|
+
JOIN chunks c ON fts_chunks.chunk_id = c.id
|
|
161
|
+
WHERE fts_chunks MATCH ? AND {filter_clause}
|
|
162
|
+
ORDER BY score DESC
|
|
163
|
+
LIMIT ?
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
rows = conn.execute(sql, [safe_q, *filter_params, k * 3]).fetchall()
|
|
167
|
+
except Exception as exc: # noqa: BLE001
|
|
168
|
+
logger.warning("FTS5 docs search failed", query=safe_q, error=str(exc))
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
return [(row[0], float(row[1])) for row in rows]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Vector search
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def _vector_docs_search(
|
|
179
|
+
conn: Connection,
|
|
180
|
+
query_embedding: list[float],
|
|
181
|
+
k: int,
|
|
182
|
+
filter_clause: str,
|
|
183
|
+
filter_params: list[Any],
|
|
184
|
+
) -> list[tuple[int, float]]:
|
|
185
|
+
"""KNN search on vec_chunks, post-filtered to docs source_type.
|
|
186
|
+
|
|
187
|
+
vec0 doesn't support filter pushdown via JOIN, so we over-fetch (k×5)
|
|
188
|
+
and apply the filter in Python — standard workaround for sqlite-vec 0.1.x.
|
|
189
|
+
"""
|
|
190
|
+
import json as _json
|
|
191
|
+
|
|
192
|
+
emb_json = _json.dumps(query_embedding)
|
|
193
|
+
try:
|
|
194
|
+
# Over-fetch to survive post-filter attrition (W3: cap at 500)
|
|
195
|
+
fetch_count = min(k * 5, 500)
|
|
196
|
+
raw = conn.execute(
|
|
197
|
+
"""
|
|
198
|
+
SELECT chunk_id, distance
|
|
199
|
+
FROM vec_chunks
|
|
200
|
+
WHERE embedding MATCH ?
|
|
201
|
+
ORDER BY distance
|
|
202
|
+
LIMIT ?
|
|
203
|
+
""",
|
|
204
|
+
(emb_json, fetch_count),
|
|
205
|
+
).fetchall()
|
|
206
|
+
except Exception as exc: # noqa: BLE001
|
|
207
|
+
logger.warning("Vector docs search failed", error=str(exc))
|
|
208
|
+
return []
|
|
209
|
+
|
|
210
|
+
if not raw:
|
|
211
|
+
return []
|
|
212
|
+
|
|
213
|
+
# Post-filter: restrict to doc chunks
|
|
214
|
+
raw_ids = [r[0] for r in raw]
|
|
215
|
+
dist_map = {r[0]: r[1] for r in raw}
|
|
216
|
+
|
|
217
|
+
ph = ",".join("?" * len(raw_ids))
|
|
218
|
+
filtered = conn.execute(
|
|
219
|
+
f"SELECT id FROM chunks WHERE id IN ({ph}) AND {filter_clause}",
|
|
220
|
+
[*raw_ids, *filter_params],
|
|
221
|
+
).fetchall()
|
|
222
|
+
filtered_ids = {row[0] for row in filtered}
|
|
223
|
+
|
|
224
|
+
results = [
|
|
225
|
+
(cid, 1.0 - dist_map[cid]) # convert distance → similarity score
|
|
226
|
+
for cid in raw_ids
|
|
227
|
+
if cid in filtered_ids
|
|
228
|
+
]
|
|
229
|
+
return results[:k]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# RRF fusion
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
def _rrf_fuse(
|
|
237
|
+
bm25: list[tuple[int, float]],
|
|
238
|
+
vector: list[tuple[int, float]],
|
|
239
|
+
k: int = RRF_K,
|
|
240
|
+
bm25_w: float = BM25_WEIGHT,
|
|
241
|
+
dense_w: float = DENSE_WEIGHT,
|
|
242
|
+
top_k: int = 10,
|
|
243
|
+
) -> list[tuple[int, float, int | None, int | None]]:
|
|
244
|
+
"""Reciprocal Rank Fusion with weights.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
List of (chunk_id, rrf_score, bm25_rank, vector_rank) sorted by score desc.
|
|
248
|
+
"""
|
|
249
|
+
bm25_ranks = {cid: rank for rank, (cid, _) in enumerate(bm25, start=1)}
|
|
250
|
+
vec_ranks = {cid: rank for rank, (cid, _) in enumerate(vector, start=1)}
|
|
251
|
+
all_ids = set(bm25_ranks) | set(vec_ranks)
|
|
252
|
+
|
|
253
|
+
scores: list[tuple[int, float, int | None, int | None]] = []
|
|
254
|
+
for cid in all_ids:
|
|
255
|
+
br = bm25_ranks.get(cid)
|
|
256
|
+
vr = vec_ranks.get(cid)
|
|
257
|
+
score = 0.0
|
|
258
|
+
if br is not None:
|
|
259
|
+
score += bm25_w / (k + br)
|
|
260
|
+
if vr is not None:
|
|
261
|
+
score += dense_w / (k + vr)
|
|
262
|
+
scores.append((cid, score, br, vr))
|
|
263
|
+
|
|
264
|
+
scores.sort(key=lambda x: x[1], reverse=True)
|
|
265
|
+
return scores[:top_k]
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
# Chunk hydration
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
def _hydrate_chunks(
|
|
273
|
+
conn: Connection, chunk_ids: list[int]
|
|
274
|
+
) -> dict[int, dict[str, Any]]:
|
|
275
|
+
"""Fetch full chunk metadata for a list of chunk IDs."""
|
|
276
|
+
if not chunk_ids:
|
|
277
|
+
return {}
|
|
278
|
+
ph = ",".join("?" * len(chunk_ids))
|
|
279
|
+
rows = conn.execute(
|
|
280
|
+
f"""
|
|
281
|
+
SELECT id, file_path, content, heading_path, heading_level,
|
|
282
|
+
chunk_type, start_line, end_line
|
|
283
|
+
FROM chunks
|
|
284
|
+
WHERE id IN ({ph})
|
|
285
|
+
""",
|
|
286
|
+
chunk_ids,
|
|
287
|
+
).fetchall()
|
|
288
|
+
return {
|
|
289
|
+
row[0]: {
|
|
290
|
+
"file_path": row[1],
|
|
291
|
+
"content": row[2],
|
|
292
|
+
"heading_path": row[3],
|
|
293
|
+
"heading_level": row[4],
|
|
294
|
+
"chunk_type": row[5],
|
|
295
|
+
"start_line": row[6],
|
|
296
|
+
"end_line": row[7],
|
|
297
|
+
}
|
|
298
|
+
for row in rows
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
# PUBLIC: docs_search
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
def docs_search(
|
|
307
|
+
query: str,
|
|
308
|
+
conn: Connection,
|
|
309
|
+
embedder: CodeEmbedder,
|
|
310
|
+
k: int = 10,
|
|
311
|
+
source_filter: dict[str, Any] | None = None,
|
|
312
|
+
) -> list[DocSearchHit]:
|
|
313
|
+
"""Hybrid BM25 + vector search over indexed documentation.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
query: Natural-language or keyword search string.
|
|
317
|
+
conn: Active SQLite reader connection.
|
|
318
|
+
embedder: Embedder singleton (jina 768-dim).
|
|
319
|
+
k: Number of results to return.
|
|
320
|
+
source_filter: Optional dict with keys: repo, file, heading, source_type.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
List of ``DocSearchHit`` objects sorted by RRF score descending.
|
|
324
|
+
"""
|
|
325
|
+
if not query.strip():
|
|
326
|
+
return []
|
|
327
|
+
|
|
328
|
+
filter_clause, filter_params = _build_source_filter_clause(source_filter)
|
|
329
|
+
|
|
330
|
+
# Embed query
|
|
331
|
+
try:
|
|
332
|
+
query_vec = embedder.embed([query])[0]
|
|
333
|
+
except Exception as exc: # noqa: BLE001
|
|
334
|
+
logger.error("Query embedding failed", error=str(exc))
|
|
335
|
+
query_vec = None
|
|
336
|
+
|
|
337
|
+
# BM25
|
|
338
|
+
bm25_results = _bm25_docs_search(conn, query, k * 3, filter_clause, filter_params)
|
|
339
|
+
|
|
340
|
+
# Vector (skip if embedder failed)
|
|
341
|
+
vector_results: list[tuple[int, float]] = []
|
|
342
|
+
if query_vec is not None:
|
|
343
|
+
vector_results = _vector_docs_search(conn, query_vec, k * 3, filter_clause, filter_params)
|
|
344
|
+
|
|
345
|
+
# RRF
|
|
346
|
+
fused = _rrf_fuse(bm25_results, vector_results, top_k=k)
|
|
347
|
+
|
|
348
|
+
if not fused:
|
|
349
|
+
return []
|
|
350
|
+
|
|
351
|
+
chunk_ids = [cid for cid, *_ in fused]
|
|
352
|
+
meta = _hydrate_chunks(conn, chunk_ids)
|
|
353
|
+
|
|
354
|
+
hits: list[DocSearchHit] = []
|
|
355
|
+
for cid, score, br, vr in fused:
|
|
356
|
+
if cid not in meta:
|
|
357
|
+
continue
|
|
358
|
+
m = meta[cid]
|
|
359
|
+
hits.append(DocSearchHit(
|
|
360
|
+
chunk_id=cid,
|
|
361
|
+
file_path=m["file_path"],
|
|
362
|
+
content=m["content"],
|
|
363
|
+
heading_path=m["heading_path"],
|
|
364
|
+
heading_level=m["heading_level"],
|
|
365
|
+
chunk_type=m["chunk_type"],
|
|
366
|
+
start_line=m["start_line"],
|
|
367
|
+
end_line=m["end_line"],
|
|
368
|
+
score=round(score, 6),
|
|
369
|
+
bm25_rank=br,
|
|
370
|
+
vector_rank=vr,
|
|
371
|
+
))
|
|
372
|
+
|
|
373
|
+
logger.debug(
|
|
374
|
+
"docs_search complete",
|
|
375
|
+
query=query[:60],
|
|
376
|
+
hits=len(hits),
|
|
377
|
+
bm25_candidates=len(bm25_results),
|
|
378
|
+
vector_candidates=len(vector_results),
|
|
379
|
+
)
|
|
380
|
+
return hits
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# ---------------------------------------------------------------------------
|
|
384
|
+
# PUBLIC: docs_get_section
|
|
385
|
+
# ---------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
def docs_get_section(
|
|
388
|
+
file: str,
|
|
389
|
+
heading_path: str,
|
|
390
|
+
conn: Connection,
|
|
391
|
+
include_subsections: bool = False,
|
|
392
|
+
fuzzy: bool = True,
|
|
393
|
+
) -> DocSection | dict[str, Any]:
|
|
394
|
+
"""Retrieve an exact document section by heading path.
|
|
395
|
+
|
|
396
|
+
5-tier resolution strategy (stops at first hit):
|
|
397
|
+
1. Exact match: heading_path == input
|
|
398
|
+
2. GitHub-slug match: slugify(heading_path) == slugify(input)
|
|
399
|
+
3. Suffix match: heading_path ends with input (leaf-only query)
|
|
400
|
+
4. RapidFuzz WRatio ≥ 72: fuzzy string match across all headings in file
|
|
401
|
+
5. FTS5 trigram fallback: content MATCH on query terms
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
file: repo-relative file path (e.g. "docs/README.md").
|
|
405
|
+
heading_path: Path like "Installation > macOS > Homebrew" or
|
|
406
|
+
"installation/macos/homebrew" or just "Homebrew".
|
|
407
|
+
conn: Active SQLite reader connection.
|
|
408
|
+
include_subsections: Append child sections' content to the result.
|
|
409
|
+
fuzzy: Enable tiers 3-5 (set False for strict exact-only).
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
``DocSection`` on success, or dict ``{"error": ..., "did_you_mean": [...]}``
|
|
413
|
+
on failure.
|
|
414
|
+
"""
|
|
415
|
+
# Normalise input
|
|
416
|
+
normalised = _normalise_heading_path(heading_path)
|
|
417
|
+
|
|
418
|
+
# Fetch all chunks for this file
|
|
419
|
+
rows = conn.execute(
|
|
420
|
+
"""
|
|
421
|
+
SELECT id, heading_path, heading_level, content, start_line, end_line
|
|
422
|
+
FROM chunks
|
|
423
|
+
WHERE file_path = ? AND source_type = 'docs'
|
|
424
|
+
ORDER BY start_line
|
|
425
|
+
""",
|
|
426
|
+
(file,),
|
|
427
|
+
).fetchall()
|
|
428
|
+
|
|
429
|
+
if not rows:
|
|
430
|
+
return {"error": "file_not_found", "file": file, "did_you_mean": []}
|
|
431
|
+
|
|
432
|
+
def _row_to_section(row: tuple) -> DocSection:
|
|
433
|
+
return DocSection(
|
|
434
|
+
file_path=file,
|
|
435
|
+
heading_path=row[1],
|
|
436
|
+
heading_level=row[2],
|
|
437
|
+
content=row[3],
|
|
438
|
+
start_line=row[4],
|
|
439
|
+
end_line=row[5],
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
all_headings = [row[1] for row in rows]
|
|
443
|
+
|
|
444
|
+
# ---- Tier 1: Exact ----
|
|
445
|
+
for row in rows:
|
|
446
|
+
if row[1] == normalised:
|
|
447
|
+
sec = _row_to_section(row)
|
|
448
|
+
if include_subsections:
|
|
449
|
+
_attach_subsections(sec, rows, row[2])
|
|
450
|
+
return sec
|
|
451
|
+
|
|
452
|
+
if not fuzzy:
|
|
453
|
+
suggestions = _top_suggestions(normalised, all_headings, n=3)
|
|
454
|
+
return {"error": "heading_not_found", "did_you_mean": suggestions}
|
|
455
|
+
|
|
456
|
+
# ---- Tier 2: GitHub slug match ----
|
|
457
|
+
slug_input = _gh_slug(normalised)
|
|
458
|
+
for row in rows:
|
|
459
|
+
if _gh_slug(row[1]) == slug_input:
|
|
460
|
+
sec = _row_to_section(row)
|
|
461
|
+
if include_subsections:
|
|
462
|
+
_attach_subsections(sec, rows, row[2])
|
|
463
|
+
return sec
|
|
464
|
+
|
|
465
|
+
# ---- Tier 3: Suffix / leaf match ----
|
|
466
|
+
leaf = normalised.split(">")[-1].strip().lower()
|
|
467
|
+
for row in rows:
|
|
468
|
+
parts = [p.strip().lower() for p in row[1].split(">")]
|
|
469
|
+
if parts and parts[-1] == leaf:
|
|
470
|
+
sec = _row_to_section(row)
|
|
471
|
+
if include_subsections:
|
|
472
|
+
_attach_subsections(sec, rows, row[2])
|
|
473
|
+
return sec
|
|
474
|
+
|
|
475
|
+
# ---- Tier 4: RapidFuzz WRatio ----
|
|
476
|
+
best_score = 0.0
|
|
477
|
+
best_row = None
|
|
478
|
+
for row in rows:
|
|
479
|
+
score = fuzz.WRatio(normalised.lower(), row[1].lower())
|
|
480
|
+
if score > best_score:
|
|
481
|
+
best_score = score
|
|
482
|
+
best_row = row
|
|
483
|
+
if best_row is not None and best_score >= FUZZY_MATCH_THRESHOLD:
|
|
484
|
+
logger.debug(
|
|
485
|
+
"Heading resolved via fuzzy match",
|
|
486
|
+
input=normalised,
|
|
487
|
+
matched=best_row[1],
|
|
488
|
+
score=best_score,
|
|
489
|
+
)
|
|
490
|
+
sec = _row_to_section(best_row)
|
|
491
|
+
if include_subsections:
|
|
492
|
+
_attach_subsections(sec, rows, best_row[2])
|
|
493
|
+
return sec
|
|
494
|
+
|
|
495
|
+
# ---- Tier 5: FTS5 trigram fallback ----
|
|
496
|
+
safe_q = re.sub(r'["\'\(\)\*\:\^]', " ", heading_path).strip()
|
|
497
|
+
try:
|
|
498
|
+
fts_row = conn.execute(
|
|
499
|
+
"""
|
|
500
|
+
SELECT c.id, c.heading_path, c.heading_level, c.content,
|
|
501
|
+
c.start_line, c.end_line
|
|
502
|
+
FROM fts_chunks
|
|
503
|
+
JOIN chunks c ON fts_chunks.chunk_id = c.id
|
|
504
|
+
WHERE fts_chunks MATCH ? AND c.file_path = ? AND c.source_type = 'docs'
|
|
505
|
+
ORDER BY -bm25(fts_chunks)
|
|
506
|
+
LIMIT 1
|
|
507
|
+
""",
|
|
508
|
+
(safe_q, file),
|
|
509
|
+
).fetchone()
|
|
510
|
+
if fts_row:
|
|
511
|
+
sec = DocSection(
|
|
512
|
+
file_path=file,
|
|
513
|
+
heading_path=fts_row[1],
|
|
514
|
+
heading_level=fts_row[2],
|
|
515
|
+
content=fts_row[3],
|
|
516
|
+
start_line=fts_row[4],
|
|
517
|
+
end_line=fts_row[5],
|
|
518
|
+
)
|
|
519
|
+
if include_subsections:
|
|
520
|
+
_attach_subsections(sec, (fts_row,), fts_row[2])
|
|
521
|
+
return sec
|
|
522
|
+
except Exception as exc: # noqa: BLE001
|
|
523
|
+
logger.debug("FTS5 fallback failed", error=str(exc))
|
|
524
|
+
|
|
525
|
+
suggestions = _top_suggestions(normalised, all_headings, n=3)
|
|
526
|
+
return {
|
|
527
|
+
"error": "heading_not_found",
|
|
528
|
+
"file": file,
|
|
529
|
+
"query": heading_path,
|
|
530
|
+
"did_you_mean": suggestions,
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# ---------------------------------------------------------------------------
|
|
535
|
+
# PUBLIC: docs_list_files
|
|
536
|
+
# ---------------------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
def docs_list_files(
|
|
539
|
+
conn: Connection,
|
|
540
|
+
repo: str | None = None,
|
|
541
|
+
glob: str | None = None,
|
|
542
|
+
include_headings: bool = False,
|
|
543
|
+
) -> list[DocFileInfo]:
|
|
544
|
+
"""List all indexed documentation files.
|
|
545
|
+
|
|
546
|
+
Args:
|
|
547
|
+
conn: Active SQLite reader connection.
|
|
548
|
+
repo: Filter by repo prefix (e.g. "src/docs").
|
|
549
|
+
glob: fnmatch glob pattern on file_path (e.g. "**/*.md").
|
|
550
|
+
include_headings: If True, attach heading_path list to each file.
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
List of ``DocFileInfo`` sorted by file_path.
|
|
554
|
+
"""
|
|
555
|
+
sql = """
|
|
556
|
+
SELECT c.file_path, COUNT(*) AS chunk_count
|
|
557
|
+
FROM chunks c
|
|
558
|
+
WHERE c.source_type = 'docs'
|
|
559
|
+
"""
|
|
560
|
+
params: list[Any] = []
|
|
561
|
+
|
|
562
|
+
if repo:
|
|
563
|
+
sql += " AND c.file_path LIKE ?"
|
|
564
|
+
params.append(f"{repo}/%")
|
|
565
|
+
|
|
566
|
+
sql += " GROUP BY c.file_path ORDER BY c.file_path"
|
|
567
|
+
|
|
568
|
+
rows = conn.execute(sql, params).fetchall()
|
|
569
|
+
|
|
570
|
+
results: list[DocFileInfo] = []
|
|
571
|
+
for file_path, chunk_count in rows:
|
|
572
|
+
# Apply glob filter in Python (fnmatch glob is not SQL)
|
|
573
|
+
if glob and not fnmatch.fnmatch(file_path, glob):
|
|
574
|
+
continue
|
|
575
|
+
|
|
576
|
+
headings: list[str] = []
|
|
577
|
+
if include_headings:
|
|
578
|
+
h_rows = conn.execute(
|
|
579
|
+
"""
|
|
580
|
+
SELECT DISTINCT heading_path
|
|
581
|
+
FROM chunks
|
|
582
|
+
WHERE file_path = ? AND source_type = 'docs'
|
|
583
|
+
AND heading_path != ''
|
|
584
|
+
ORDER BY start_line
|
|
585
|
+
""",
|
|
586
|
+
(file_path,),
|
|
587
|
+
).fetchall()
|
|
588
|
+
headings = [r[0] for r in h_rows]
|
|
589
|
+
|
|
590
|
+
results.append(DocFileInfo(
|
|
591
|
+
file_path=file_path,
|
|
592
|
+
chunk_count=chunk_count,
|
|
593
|
+
headings=headings,
|
|
594
|
+
))
|
|
595
|
+
|
|
596
|
+
return results
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
# ---------------------------------------------------------------------------
|
|
600
|
+
# Heading helpers
|
|
601
|
+
# ---------------------------------------------------------------------------
|
|
602
|
+
|
|
603
|
+
def _normalise_heading_path(raw: str) -> str:
|
|
604
|
+
"""Normalise heading path: strip whitespace, convert '/' to ' > '."""
|
|
605
|
+
# Accept both "Installation > macOS" and "installation/macos" styles
|
|
606
|
+
normalised = raw.replace("/", " > ").strip()
|
|
607
|
+
# Collapse multiple spaces
|
|
608
|
+
normalised = re.sub(r"\s{2,}", " ", normalised)
|
|
609
|
+
return normalised
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _gh_slug(heading: str) -> str:
|
|
613
|
+
"""GitHub-flavored markdown heading → anchor slug."""
|
|
614
|
+
slug = heading.lower()
|
|
615
|
+
slug = re.sub(r"[^\w\s-]", "", slug)
|
|
616
|
+
slug = re.sub(r"\s+", "-", slug.strip())
|
|
617
|
+
return slug
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _top_suggestions(
|
|
621
|
+
query: str, candidates: list[str], n: int = 3
|
|
622
|
+
) -> list[str]:
|
|
623
|
+
"""Return top-N fuzzy matches from candidates for a not-found heading."""
|
|
624
|
+
scored = sorted(
|
|
625
|
+
candidates,
|
|
626
|
+
key=lambda h: fuzz.WRatio(query.lower(), h.lower()),
|
|
627
|
+
reverse=True,
|
|
628
|
+
)
|
|
629
|
+
return scored[:n]
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _attach_subsections(
|
|
633
|
+
parent: DocSection,
|
|
634
|
+
all_rows: tuple | list,
|
|
635
|
+
parent_level: int,
|
|
636
|
+
) -> None:
|
|
637
|
+
"""Attach child sections (deeper heading level) to parent.subsections."""
|
|
638
|
+
capturing = False
|
|
639
|
+
for row in all_rows:
|
|
640
|
+
hp, lvl, content, sl, el = row[1], row[2], row[3], row[4], row[5]
|
|
641
|
+
if hp == parent.heading_path and lvl == parent_level:
|
|
642
|
+
capturing = True
|
|
643
|
+
continue
|
|
644
|
+
if capturing:
|
|
645
|
+
if lvl <= parent_level:
|
|
646
|
+
break
|
|
647
|
+
parent.subsections.append(DocSection(
|
|
648
|
+
file_path=parent.file_path,
|
|
649
|
+
heading_path=hp,
|
|
650
|
+
heading_level=lvl,
|
|
651
|
+
content=content,
|
|
652
|
+
start_line=sl,
|
|
653
|
+
end_line=el,
|
|
654
|
+
))
|