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.
Files changed (60) hide show
  1. contextual/__init__.py +18 -0
  2. contextual/__main__.py +11 -0
  3. contextual/cli.py +339 -0
  4. contextual/cli_docs.py +685 -0
  5. contextual/config.py +7 -0
  6. contextual/core/__init__.py +11 -0
  7. contextual/core/errors.py +470 -0
  8. contextual/core/models.py +590 -0
  9. contextual/docs/__init__.py +66 -0
  10. contextual/docs/chunker.py +550 -0
  11. contextual/docs/pipeline.py +513 -0
  12. contextual/docs/retrieval.py +654 -0
  13. contextual/docs/watcher.py +265 -0
  14. contextual/embedding/__init__.py +87 -0
  15. contextual/embedding/cache.py +455 -0
  16. contextual/embedding/embedder.py +414 -0
  17. contextual/embedding/helpers.py +252 -0
  18. contextual/git/__init__.py +22 -0
  19. contextual/git/blame.py +334 -0
  20. contextual/indexing/__init__.py +20 -0
  21. contextual/indexing/bug_sweep.py +119 -0
  22. contextual/indexing/chunker.py +691 -0
  23. contextual/indexing/embedder.py +271 -0
  24. contextual/indexing/file_watcher.py +154 -0
  25. contextual/indexing/incremental.py +260 -0
  26. contextual/indexing/index_writer.py +442 -0
  27. contextual/indexing/pipeline.py +438 -0
  28. contextual/indexing/processor.py +436 -0
  29. contextual/indexing/queries/readme.md +22 -0
  30. contextual/indexing/symbol_extractor.py +426 -0
  31. contextual/indexing/tokenizer.py +203 -0
  32. contextual/integrations/__init__.py +10 -0
  33. contextual/mcp/__init__.py +15 -0
  34. contextual/mcp/__main__.py +24 -0
  35. contextual/mcp/docs_tools.py +286 -0
  36. contextual/mcp/server.py +118 -0
  37. contextual/mcp/tools.py +443 -0
  38. contextual/observability/__init__.py +21 -0
  39. contextual/observability/logging.py +115 -0
  40. contextual/py.typed +0 -0
  41. contextual/retrieval/__init__.py +24 -0
  42. contextual/retrieval/context_assembler.py +372 -0
  43. contextual/retrieval/ranker.py +193 -0
  44. contextual/retrieval/search.py +548 -0
  45. contextual/security/__init__.py +52 -0
  46. contextual/security/paths.py +347 -0
  47. contextual/security/sanitize.py +349 -0
  48. contextual/security/workspace.py +348 -0
  49. contextual/storage/__init__.py +36 -0
  50. contextual/storage/fts_manager.py +273 -0
  51. contextual/storage/migration_v2.py +289 -0
  52. contextual/storage/migrations.py +316 -0
  53. contextual/storage/schema.py +210 -0
  54. contextual/storage/sqlite_pool.py +468 -0
  55. contextual/storage/vec0_manager.py +421 -0
  56. contextual_engine-0.1.0.dist-info/METADATA +297 -0
  57. contextual_engine-0.1.0.dist-info/RECORD +60 -0
  58. contextual_engine-0.1.0.dist-info/WHEEL +4 -0
  59. contextual_engine-0.1.0.dist-info/entry_points.txt +2 -0
  60. 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
+ ))