revit-api-docs 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 (36) hide show
  1. revit_api_docs/__init__.py +18 -0
  2. revit_api_docs/__main__.py +8 -0
  3. revit_api_docs/config.py +115 -0
  4. revit_api_docs/data.py +320 -0
  5. revit_api_docs/default_config.yaml +85 -0
  6. revit_api_docs/embedder/__init__.py +1 -0
  7. revit_api_docs/embedder/providers/__init__.py +25 -0
  8. revit_api_docs/embedder/providers/base.py +28 -0
  9. revit_api_docs/embedder/providers/google.py +36 -0
  10. revit_api_docs/embedder/providers/openai.py +58 -0
  11. revit_api_docs/llm_client.py +288 -0
  12. revit_api_docs/prompts/__init__.py +5 -0
  13. revit_api_docs/prompts/loader.py +20 -0
  14. revit_api_docs/prompts/pipeline.api_quality_stage1_no_html.md +28 -0
  15. revit_api_docs/prompts/pipeline.api_quality_stage1_system.md +1 -0
  16. revit_api_docs/prompts/pipeline.api_quality_stage1_with_html.md +35 -0
  17. revit_api_docs/prompts/pipeline.api_quality_stage2.md +33 -0
  18. revit_api_docs/prompts/pipeline.api_quality_stage2_system.md +1 -0
  19. revit_api_docs/prompts/pipeline.rewrite_query.md +21 -0
  20. revit_api_docs/prompts/pipeline.rewrite_query_system.md +1 -0
  21. revit_api_docs/prompts/pipeline.sdk_extract_readme_system.md +1 -0
  22. revit_api_docs/prompts/pipeline.sdk_extract_readme_user.md +22 -0
  23. revit_api_docs/prompts/pipeline.sdk_golden_code.md +32 -0
  24. revit_api_docs/prompts/pipeline.sdk_golden_code_system.md +1 -0
  25. revit_api_docs/prompts/pipeline.sdk_quality_file_batch.md +17 -0
  26. revit_api_docs/prompts/pipeline.sdk_quality_file_system.md +1 -0
  27. revit_api_docs/prompts/pipeline.sdk_quality_project.md +20 -0
  28. revit_api_docs/prompts/pipeline.sdk_quality_project_system.md +1 -0
  29. revit_api_docs/reranker.py +177 -0
  30. revit_api_docs/retriever.py +901 -0
  31. revit_api_docs/server.py +244 -0
  32. revit_api_docs-0.1.0.dist-info/METADATA +139 -0
  33. revit_api_docs-0.1.0.dist-info/RECORD +36 -0
  34. revit_api_docs-0.1.0.dist-info/WHEEL +4 -0
  35. revit_api_docs-0.1.0.dist-info/entry_points.txt +2 -0
  36. revit_api_docs-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,901 @@
1
+ """
2
+ Two-tier retriever — ChromaDB (semantic index) + SQLite (content store).
3
+
4
+ Architecture:
5
+ ChromaDB holds lightweight embeddings (summary-based) + document IDs.
6
+ SQLite holds the full content (code, API docs, parameters, etc.).
7
+ At query time:
8
+ 0. (Optional) LLM query rewriting: extract Revit API keywords from user query
9
+ 1. Embed the rewritten query and search ChromaDB → ranked IDs + scores
10
+ 2. Batch-fetch full records from SQLite by ID
11
+ 3. Assemble structured context dicts ready for prompt injection
12
+
13
+ Public API:
14
+ retriever = RAGRetriever(config, api_db, sdk_db, chromadb_api_dir, chromadb_code_dir)
15
+ results = retriever.search(query, api_top_k=15, code_top_k=5)
16
+ context = retriever.build_context(results)
17
+
18
+ Degraded modes (no network keys needed for the MCP server to be useful):
19
+ - No embedding API key -> vector search is skipped, SQLite keyword search only.
20
+ - No rerank API key -> results keep vector/keyword order.
21
+ - No LLM key -> query rewriting is skipped.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import re
28
+ import sqlite3
29
+ import threading
30
+ from dataclasses import dataclass, field
31
+ from typing import Any
32
+
33
+ from .prompts import load_prompt
34
+
35
+
36
+ def _escape_like(s: str) -> str:
37
+ """Escape LIKE wildcards % and _ (used with ESCAPE '\\'); the backslash itself too."""
38
+ return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
39
+
40
+
41
+ @dataclass
42
+ class RetrievedItem:
43
+ """A single retrieved item with full content from SQLite."""
44
+ source: str # "api" or "sdk"
45
+ chromadb_id: str
46
+ distance: float
47
+ # API fields
48
+ name: str = ""
49
+ full_id: str = ""
50
+ summary: str = ""
51
+ info: str = ""
52
+ syntax: str = ""
53
+ parameters: str = ""
54
+ remark: str = ""
55
+ # SDK fields
56
+ project: str = ""
57
+ content: str = "" # golden code snippet
58
+ mentioned_apis: str = ""
59
+
60
+
61
+ @dataclass
62
+ class SearchResults:
63
+ """Container for all retrieval results."""
64
+ query: str
65
+ rewritten_query: str = ""
66
+ api_items: list[RetrievedItem] = field(default_factory=list)
67
+ sdk_items: list[RetrievedItem] = field(default_factory=list)
68
+
69
+
70
+ class RAGRetriever:
71
+ """
72
+ Two-tier retriever: ChromaDB (semantic search) + SQLite (content store)
73
+ with optional post-retrieval reranking.
74
+
75
+ Works with both existing (pre-trained) API ChromaDB and new SDK ChromaDB
76
+ without requiring re-embedding.
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ config: dict[str, Any],
82
+ api_db_path: str,
83
+ sdk_db_path: str,
84
+ chromadb_api_dir: str,
85
+ chromadb_code_dir: str,
86
+ ):
87
+ import logging
88
+ self._log = logging.getLogger("revit_api_docs.retriever")
89
+
90
+ from .embedder.providers import create_embedding
91
+ try:
92
+ self._embedder = create_embedding(config)
93
+ except (KeyError, ValueError, ImportError, NotImplementedError) as e:
94
+ # No key / unknown provider / provider not implemented (local_hf,
95
+ # zhipu) / SDK not installed: keyword search only.
96
+ self._log.warning(f"embedding disabled ({e}); vector search is off, "
97
+ "keyword search on SQLite only")
98
+ self._embedder = None
99
+ self._config = config
100
+
101
+ # Retrieval config
102
+ ret_cfg = config.get("retrieval", {})
103
+ self._api_top_k = ret_cfg.get("api", {}).get("top_k", 30)
104
+ self._api_rerank_n = ret_cfg.get("api", {}).get("rerank_top_n", 15)
105
+ self._code_top_k = ret_cfg.get("code", {}).get("top_k", 5)
106
+ self._code_rerank_n = ret_cfg.get("code", {}).get("rerank_top_n", 3)
107
+
108
+ # ChromaDB collections are opened on the first vector query (see
109
+ # _open_collections); without an embedder they are never touched.
110
+ self._chromadb_api_dir = chromadb_api_dir
111
+ self._chromadb_code_dir = chromadb_code_dir
112
+ self._api_collection = None
113
+ self._code_collection = None
114
+ self._collections_lock = threading.Lock()
115
+ if self._embedder is not None:
116
+ for label, d in [("api", chromadb_api_dir), ("code", chromadb_code_dir)]:
117
+ if not os.path.isdir(d):
118
+ self._log.error(f"ChromaDB {label} dir missing: {d}")
119
+ elif not os.path.exists(os.path.join(d, "chroma.sqlite3")):
120
+ self._log.error(f"ChromaDB {label} dir has no chroma.sqlite3: {d}")
121
+
122
+ # SQLite paths (opened per-query to avoid threading issues)
123
+ self._api_db = api_db_path
124
+ self._sdk_db = sdk_db_path
125
+
126
+ # Consistency check: record_count in the ChromaDB meta.json should equal
127
+ # the SQLite revit_api row count; otherwise the two stores are out of sync.
128
+ try:
129
+ meta_path = os.path.join(chromadb_api_dir, "meta.json")
130
+ if os.path.exists(meta_path):
131
+ with open(meta_path, "r", encoding="utf-8") as f:
132
+ meta_count = json.load(f).get("record_count")
133
+ conn = sqlite3.connect(api_db_path)
134
+ sqlite_count = conn.execute("SELECT COUNT(*) FROM revit_api").fetchone()[0]
135
+ conn.close()
136
+ if meta_count is not None and meta_count != sqlite_count:
137
+ self._log.warning(
138
+ "index/content mismatch: ChromaDB meta.record_count=%s but SQLite "
139
+ "revit_api has %s rows; vectors without a row are dropped at query time "
140
+ "(rebuild the ChromaDB index after parse_chm rebuilds SQLite)",
141
+ meta_count, sqlite_count,
142
+ )
143
+ except Exception as e:
144
+ self._log.warning(f"index/content consistency check failed: {e}")
145
+
146
+ # Detect SDK schema
147
+ self._sdk_new_schema = self._detect_sdk_schema()
148
+
149
+ # Vector search is switched off for the process after this many
150
+ # consecutive embedding/index failures (expired key, unreachable API).
151
+ self._embed_failures = 0
152
+ self._max_embed_failures = 3
153
+
154
+ # Query rewriting LLM (lazy init; stays None after a failed init)
155
+ self._rewrite_client = None
156
+ self._rewrite_unavailable = False
157
+
158
+ # Reranker (lazy init from config)
159
+ self._reranker = None
160
+ self._reranker_initialized = False
161
+
162
+ def _open_collections(self) -> None:
163
+ """Open both ChromaDB collections (slow import, done once, lazily).
164
+
165
+ Tool calls run in worker threads, so two first queries can race here;
166
+ the lock makes the second one wait instead of seeing one collection
167
+ set and the other still None. Both are published together, after the
168
+ second open succeeded.
169
+ """
170
+ if self._code_collection is not None:
171
+ return
172
+ with self._collections_lock:
173
+ if self._code_collection is not None:
174
+ return
175
+ import chromadb
176
+ from chromadb.config import Settings
177
+
178
+ settings = Settings(anonymized_telemetry=False)
179
+ api = (
180
+ chromadb.PersistentClient(path=self._chromadb_api_dir, settings=settings)
181
+ .get_collection("revit_api")
182
+ )
183
+ code = (
184
+ chromadb.PersistentClient(path=self._chromadb_code_dir, settings=settings)
185
+ .get_collection("revit_sdk")
186
+ )
187
+ self._api_collection, self._code_collection = api, code
188
+
189
+ @property
190
+ def vector_search_enabled(self) -> bool:
191
+ return self._embedder is not None
192
+
193
+ def _detect_sdk_schema(self) -> bool:
194
+ """Check if sdk_db uses the new sdk_info table."""
195
+ if not os.path.exists(self._sdk_db):
196
+ raise FileNotFoundError(f"SDK DB not found: {self._sdk_db}")
197
+ conn = sqlite3.connect(self._sdk_db)
198
+ try:
199
+ conn.execute("SELECT id FROM sdk_info LIMIT 1")
200
+ return True
201
+ except sqlite3.OperationalError:
202
+ # table missing / schema mismatch -> legacy schema
203
+ return False
204
+ finally:
205
+ conn.close()
206
+
207
+ def _get_reranker(self):
208
+ """Lazy-init reranker from config."""
209
+ if not self._reranker_initialized:
210
+ self._reranker_initialized = True
211
+ try:
212
+ from .reranker import create_reranker
213
+ self._reranker = create_reranker(self._config)
214
+ except Exception as e:
215
+ self._log.warning(f"[_get_reranker] failed to init: {e}")
216
+ self._reranker = None
217
+ return self._reranker
218
+
219
+ # ------------------------------------------------------------------
220
+ # Query rewriting
221
+ # ------------------------------------------------------------------
222
+
223
+ _REWRITE_PROMPT = load_prompt("pipeline.rewrite_query.md")
224
+ _REWRITE_SYSTEM_PROMPT = load_prompt("pipeline.rewrite_query_system.md")
225
+
226
+ def _get_rewrite_client(self):
227
+ """Lazy-init the query rewriting LLM client (None when no LLM is configured)."""
228
+ if self._rewrite_client is None and not self._rewrite_unavailable:
229
+ from .llm_client import create_llm_client
230
+ try:
231
+ self._rewrite_client = create_llm_client(
232
+ self._config, provider_override="gemini_flash"
233
+ )
234
+ self._rewrite_client.max_tokens = 256
235
+ self._rewrite_client.temperature = 0.1
236
+ except Exception as e:
237
+ self._rewrite_unavailable = True
238
+ self._log.info(f"query rewriting disabled ({e})")
239
+ return self._rewrite_client
240
+
241
+ def rewrite_query(self, query: str) -> str:
242
+ """
243
+ Use LLM to extract Revit API keywords from user query.
244
+ Returns enriched query string for embedding.
245
+ Falls back to original query on any error.
246
+ """
247
+ client = self._get_rewrite_client()
248
+ if client is None:
249
+ return query
250
+ try:
251
+ raw = client.generate_text(
252
+ self._REWRITE_PROMPT.format(query=query),
253
+ system_prompt=self._REWRITE_SYSTEM_PROMPT,
254
+ )
255
+ raw = re.sub(r"^```(?:json)?\s*", "", raw.strip(), flags=re.IGNORECASE)
256
+ raw = re.sub(r"\s*```$", "", raw.strip())
257
+ result = json.loads(raw)
258
+ keywords = result.get("keywords", "")
259
+ api_terms = result.get("api_terms", [])
260
+ # Combine: original query + extracted keywords + API terms
261
+ enriched = f"{query} {keywords} {' '.join(api_terms)}"
262
+ return enriched.strip()
263
+ except Exception:
264
+ return query
265
+
266
+ # ------------------------------------------------------------------
267
+ # Search
268
+ # ------------------------------------------------------------------
269
+
270
+ def search(
271
+ self,
272
+ query: str,
273
+ api_top_k: int | None = None,
274
+ code_top_k: int | None = None,
275
+ rewrite: bool = True,
276
+ rerank: bool | None = None,
277
+ ) -> SearchResults:
278
+ """
279
+ Tier 0: (Optional) LLM query rewriting for better keyword alignment.
280
+ Tier 1: Embed query → search both ChromaDB collections (broad recall).
281
+ Tier 2: Fetch full records from SQLite by matched IDs.
282
+ Tier 3: (Optional) Rerank results for precision using cross-encoder.
283
+
284
+ `rerank=None` follows `rewrite` (the historical fast/full switch); pass
285
+ `rerank=True` with `rewrite=False` to rerank without an LLM rewrite.
286
+ Pass 0 as a top_k to skip that source entirely.
287
+ """
288
+ import time as _time
289
+ t0 = _time.monotonic()
290
+
291
+ # Use config defaults if not specified (0 means "none of this source")
292
+ final_api_n = self._api_rerank_n if api_top_k is None else api_top_k
293
+ final_code_n = self._code_rerank_n if code_top_k is None else code_top_k
294
+
295
+ # In fast mode: retrieve exactly final_api_n, skip rewrite + rerank
296
+ # In full mode: retrieve broad (top_k), then rerank down to final_api_n
297
+ # A source whose final count is 0 is not queried at all.
298
+ use_rerank = rewrite if rerank is None else rerank
299
+ retrieve_api_k = (self._api_top_k if use_rerank else final_api_n) if final_api_n > 0 else 0
300
+ retrieve_code_k = (self._code_top_k if use_rerank else final_code_n) if final_code_n > 0 else 0
301
+
302
+ # Tier 0: Query rewriting
303
+ search_query = self.rewrite_query(query) if rewrite else query
304
+ t_rewrite = _time.monotonic() - t0
305
+
306
+ # Tier 1: Embedding + Vector search (skipped without an embedding key;
307
+ # a failing embedding API degrades to keyword search instead of erroring)
308
+ api_items: list[RetrievedItem] = []
309
+ sdk_items: list[RetrievedItem] = []
310
+ t_embed = 0.0
311
+ vector_ok = False
312
+ if self._embedder is not None:
313
+ try:
314
+ self._open_collections()
315
+ query_embedding = self._embedder.embed_query(search_query)
316
+ t_embed = _time.monotonic() - t0 - t_rewrite
317
+
318
+ if retrieve_api_k > 0:
319
+ api_raw = self._api_collection.query(
320
+ query_embeddings=[query_embedding],
321
+ n_results=retrieve_api_k,
322
+ )
323
+ api_items = self._hydrate_api(api_raw)
324
+ if retrieve_code_k > 0:
325
+ code_raw = self._code_collection.query(
326
+ query_embeddings=[query_embedding],
327
+ n_results=retrieve_code_k,
328
+ )
329
+ sdk_items = self._hydrate_sdk(code_raw)
330
+ vector_ok = True
331
+ self._embed_failures = 0
332
+ except Exception as e:
333
+ self._embed_failures += 1
334
+ self._log.warning(f"vector search failed ({type(e).__name__}: {str(e)[:200]}); "
335
+ "using keyword search for this query")
336
+ if self._embed_failures >= self._max_embed_failures:
337
+ self._log.warning("vector search disabled for this process after "
338
+ f"{self._embed_failures} consecutive failures")
339
+ self._embedder = None
340
+ api_items, sdk_items = [], []
341
+ if not vector_ok and final_code_n > 0:
342
+ sdk_items = self._keyword_search_sdk(query, limit=final_code_n)
343
+
344
+ # Tier 2: Hydrate from SQLite (done above per source)
345
+ t_hydrate = _time.monotonic() - t0 - t_rewrite - t_embed
346
+
347
+ # Tier 2.5: Keyword search on SQLite — merge with vector results
348
+ # This catches exact name/API matches that embedding search misses
349
+ kw_items = self._keyword_search_api(query, limit=final_api_n) if final_api_n > 0 else []
350
+ if kw_items:
351
+ existing_ids = {item.chromadb_id for item in api_items}
352
+ merged_count = 0
353
+ for kw_item in kw_items:
354
+ if kw_item.chromadb_id not in existing_ids:
355
+ api_items.append(kw_item)
356
+ existing_ids.add(kw_item.chromadb_id)
357
+ merged_count += 1
358
+ else:
359
+ # Keyword match is also in vector results — boost its ranking
360
+ for ai in api_items:
361
+ if ai.chromadb_id == kw_item.chromadb_id:
362
+ ai.distance = min(ai.distance, kw_item.distance)
363
+ break
364
+ if merged_count:
365
+ self._log.info(f"[search] merged {merged_count} keyword results")
366
+ # Re-sort by distance after merging
367
+ api_items.sort(key=lambda x: x.distance)
368
+
369
+ # Tier 3: Rerank (only in full mode)
370
+ t_rerank = 0.0
371
+ reranker = self._get_reranker() if use_rerank else None
372
+ if reranker and api_items:
373
+ t_rr_start = _time.monotonic()
374
+ # Build document texts for reranking
375
+ api_docs = []
376
+ for item in api_items:
377
+ doc = f"{item.full_id or item.name}: {item.summary}"
378
+ if item.syntax:
379
+ doc += f" | {item.syntax}"
380
+ api_docs.append(doc)
381
+
382
+ reranked = reranker.rerank(query, api_docs, top_n=final_api_n)
383
+ # Reorder api_items by rerank scores
384
+ reranked_items = []
385
+ for rr in reranked:
386
+ if rr.index < len(api_items):
387
+ item = api_items[rr.index]
388
+ item.distance = 1.0 - rr.score # convert score to distance
389
+ reranked_items.append(item)
390
+ api_items = reranked_items
391
+ t_rerank = _time.monotonic() - t_rr_start
392
+ self._log.info(f"[search] reranked {len(api_docs)} → {len(api_items)} "
393
+ f"in {t_rerank:.2f}s")
394
+ else:
395
+ # No reranker — just trim to final count
396
+ api_items = api_items[:final_api_n]
397
+
398
+ sdk_items = sdk_items[:final_code_n]
399
+
400
+ elapsed = _time.monotonic() - t0
401
+ self._log.info(
402
+ f"[search] query={query[:40]!r} rewrite={t_rewrite:.2f}s "
403
+ f"embed={t_embed:.2f}s hydrate={t_hydrate:.2f}s "
404
+ f"rerank={t_rerank:.2f}s total={elapsed:.2f}s "
405
+ f"api={len(api_items)} sdk={len(sdk_items)}"
406
+ )
407
+
408
+ return SearchResults(
409
+ query=query, rewritten_query=search_query,
410
+ api_items=api_items, sdk_items=sdk_items,
411
+ )
412
+
413
+ # ------------------------------------------------------------------
414
+ # Hydrate from SQLite
415
+ # ------------------------------------------------------------------
416
+
417
+ def _hydrate_api(self, raw: dict) -> list[RetrievedItem]:
418
+ """Fetch full API records from SQLite by ChromaDB IDs."""
419
+ ids = raw["ids"][0] if raw["ids"] else []
420
+ distances = raw["distances"][0] if raw["distances"] else []
421
+ if not ids:
422
+ return []
423
+
424
+ conn = sqlite3.connect(self._api_db)
425
+ conn.row_factory = sqlite3.Row
426
+ placeholders = ",".join("?" * len(ids))
427
+ rows = conn.execute(
428
+ f"SELECT id, name, full_id, summary, info, syntax, parameters, remark "
429
+ f"FROM revit_api WHERE id IN ({placeholders})",
430
+ [int(i) for i in ids],
431
+ ).fetchall()
432
+ conn.close()
433
+
434
+ row_map = {str(r["id"]): r for r in rows}
435
+
436
+ items = []
437
+ for cid, dist in zip(ids, distances):
438
+ r = row_map.get(cid)
439
+ if r is None:
440
+ continue
441
+ items.append(RetrievedItem(
442
+ source="api",
443
+ chromadb_id=cid,
444
+ distance=dist,
445
+ name=r["name"] or "",
446
+ full_id=r["full_id"] or "",
447
+ summary=r["summary"] or "",
448
+ info=r["info"] or "",
449
+ syntax=r["syntax"] or "",
450
+ parameters=r["parameters"] or "",
451
+ remark=r["remark"] or "",
452
+ ))
453
+ return items
454
+
455
+ def _hydrate_sdk(self, raw: dict) -> list[RetrievedItem]:
456
+ """Fetch full SDK records from SQLite by ChromaDB IDs."""
457
+ ids = raw["ids"][0] if raw["ids"] else []
458
+ distances = raw["distances"][0] if raw["distances"] else []
459
+ if not ids:
460
+ return []
461
+
462
+ conn = sqlite3.connect(self._sdk_db)
463
+ conn.row_factory = sqlite3.Row
464
+
465
+ placeholders = ",".join("?" * len(ids))
466
+ int_ids = [int(i) for i in ids]
467
+
468
+ if self._sdk_new_schema:
469
+ rows = conn.execute(
470
+ f"SELECT id, project, summary, content, mentioned_apis "
471
+ f"FROM sdk_info WHERE id IN ({placeholders})",
472
+ int_ids,
473
+ ).fetchall()
474
+ row_map = {str(r["id"]): r for r in rows}
475
+ else:
476
+ rows = conn.execute(
477
+ f"SELECT id, project, filename, code, clean_code, description "
478
+ f"FROM revit_sdk WHERE id IN ({placeholders})",
479
+ int_ids,
480
+ ).fetchall()
481
+ row_map = {str(r["id"]): r for r in rows}
482
+
483
+ conn.close()
484
+
485
+ items = []
486
+ for cid, dist in zip(ids, distances):
487
+ r = row_map.get(cid)
488
+ if r is None:
489
+ continue
490
+ if self._sdk_new_schema:
491
+ items.append(RetrievedItem(
492
+ source="sdk",
493
+ chromadb_id=cid,
494
+ distance=dist,
495
+ project=r["project"] or "",
496
+ summary=r["summary"] or "",
497
+ content=r["content"] or "",
498
+ mentioned_apis=r["mentioned_apis"] or "",
499
+ ))
500
+ else:
501
+ code = r["clean_code"] if r["clean_code"] else (r["code"] or "")
502
+ items.append(RetrievedItem(
503
+ source="sdk",
504
+ chromadb_id=cid,
505
+ distance=dist,
506
+ project=r["project"] or "",
507
+ name=r["filename"] or "",
508
+ summary=r["description"] or "",
509
+ content=code,
510
+ ))
511
+ return items
512
+
513
+ # ------------------------------------------------------------------
514
+ # Keyword normalization
515
+ # ------------------------------------------------------------------
516
+
517
+ # Common stop words that add noise to Revit API keyword search
518
+ _STOP_WORDS = frozenset({
519
+ "i", "me", "my", "we", "our", "you", "your", "he", "she", "it",
520
+ "the", "a", "an", "is", "am", "are", "was", "were", "be", "been",
521
+ "do", "does", "did", "have", "has", "had", "will", "would", "can",
522
+ "could", "should", "shall", "may", "might", "must",
523
+ "want", "need", "like", "know", "think", "use", "using", "used",
524
+ "get", "got", "getting", "let", "make", "making",
525
+ "to", "of", "in", "for", "on", "at", "by", "with", "from",
526
+ "about", "into", "through", "after", "before", "between",
527
+ "and", "or", "but", "not", "no", "if", "then", "so", "than",
528
+ "how", "what", "which", "who", "where", "when", "why",
529
+ "all", "any", "some", "this", "that", "these", "those",
530
+ "api", "apis", "revit", "method", "function", "class",
531
+ "please", "help", "show", "find", "look", "looking", "search",
532
+ })
533
+
534
+ # Verb → base form mapping for common Revit-relevant verbs
535
+ _VERB_STEMS = {
536
+ "creates": "create", "created": "create", "creating": "create",
537
+ "deletes": "delete", "deleted": "delete", "deleting": "delete",
538
+ "moves": "move", "moved": "move", "moving": "move",
539
+ "copies": "copy", "copied": "copy", "copying": "copy",
540
+ "modifies": "modify", "modified": "modify", "modifying": "modify",
541
+ "changes": "change", "changed": "change", "changing": "change",
542
+ "sets": "set", "setting": "set",
543
+ "gets": "get", "getting": "get", # "get" itself is a stop word but stem kept for compounds
544
+ "adds": "add", "added": "add", "adding": "add",
545
+ "removes": "remove", "removed": "remove", "removing": "remove",
546
+ "updates": "update", "updated": "update", "updating": "update",
547
+ "opens": "open", "opened": "open", "opening": "open",
548
+ "closes": "close", "closed": "close", "closing": "close",
549
+ "selects": "select", "selected": "select", "selecting": "select",
550
+ "filters": "filter", "filtered": "filter", "filtering": "filter",
551
+ "places": "place", "placed": "place", "placing": "place",
552
+ "splits": "split", "splitting": "split",
553
+ "joins": "join", "joined": "join", "joining": "join",
554
+ "exports": "export", "exported": "export", "exporting": "export",
555
+ "imports": "import", "imported": "import", "importing": "import",
556
+ "rotates": "rotate", "rotated": "rotate", "rotating": "rotate",
557
+ "mirrors": "mirror", "mirrored": "mirror", "mirroring": "mirror",
558
+ }
559
+
560
+ # Tokens that are likely Revit entity nouns (boosted in scoring)
561
+ _ENTITY_NOUNS = frozenset({
562
+ "wall", "floor", "ceiling", "roof", "door", "window", "room",
563
+ "column", "beam", "pipe", "duct", "family", "type", "level",
564
+ "grid", "view", "sheet", "schedule", "parameter", "element",
565
+ "group", "assembly", "material", "phase", "workset", "link",
566
+ "rebar", "stair", "railing", "ramp", "curtain", "panel",
567
+ "mullion", "opening", "dimension", "tag", "annotation",
568
+ "detail", "section", "elevation", "plan", "legend",
569
+ "connector", "fitting", "system", "zone", "space", "area",
570
+ "part", "instance", "symbol", "category", "filter",
571
+ "transaction", "collector", "reference", "curve", "line",
572
+ "arc", "point", "solid", "face", "edge", "geometry",
573
+ })
574
+
575
+ def _normalize_search_tokens(self, query: str) -> list[tuple[str, float]]:
576
+ """Extract and weight search tokens from a natural-language query.
577
+
578
+ Returns list of (token, weight) tuples where:
579
+ - Entity nouns (wall, floor, etc.) get weight 2.0
580
+ - Stemmed action verbs (create, delete) get weight 1.0
581
+ - Other surviving tokens get weight 0.5
582
+ Stop words are removed, verbs are stemmed to base form.
583
+ """
584
+ raw_tokens = [t.strip().lower() for t in query.split() if len(t.strip()) >= 2]
585
+ weighted: list[tuple[str, float]] = []
586
+ seen = set()
587
+
588
+ for token in raw_tokens:
589
+ # Stem verbs first
590
+ stemmed = self._VERB_STEMS.get(token, token)
591
+
592
+ # Skip stop words only when BOTH the original token and its stem are
593
+ # stop words (so verb forms like "gets"→"get" that are also action
594
+ # stems can survive if the stem is meaningful elsewhere).
595
+ if token in self._STOP_WORDS and stemmed in self._STOP_WORDS:
596
+ continue
597
+
598
+ if stemmed in seen:
599
+ continue
600
+ seen.add(stemmed)
601
+
602
+ if stemmed in self._ENTITY_NOUNS:
603
+ weighted.append((stemmed, 2.0))
604
+ elif token in self._VERB_STEMS:
605
+ # It was a verb form → stemmed action verb
606
+ weighted.append((stemmed, 1.0))
607
+ else:
608
+ weighted.append((stemmed, 0.5))
609
+
610
+ # Sort by weight descending so most important tokens come first
611
+ weighted.sort(key=lambda x: x[1], reverse=True)
612
+ return weighted
613
+
614
+ # ------------------------------------------------------------------
615
+ # Keyword search (SQLite direct)
616
+ # ------------------------------------------------------------------
617
+
618
+ # Candidates handed from SQL to the Python scorer. The cap is applied after
619
+ # SQL has ranked every matching row, never to an arbitrary rowid subset.
620
+ _KEYWORD_CANDIDATES = 300
621
+
622
+ def _keyword_candidates(self, weighted_tokens: list[tuple[str, float]]) -> list[sqlite3.Row]:
623
+ """All rows matching any token, ordered by a coarse SQL relevance score.
624
+
625
+ Per token: name hit 5*w, full_id hit 1.5*w, summary hit 0.5*w (the same
626
+ weights the Python scorer starts from), so rows with several tokens in
627
+ the name come first and the LIMIT trims the long tail of summary-only
628
+ hits instead of dropping exact name matches.
629
+ """
630
+ score_terms: list[str] = []
631
+ params: list[str] = []
632
+ for token, w in weighted_tokens:
633
+ like = f"%{_escape_like(token)}%"
634
+ score_terms.append(
635
+ f"(LOWER(name) LIKE ? ESCAPE '\\') * {5.0 * w:.2f}"
636
+ f" + (LOWER(full_id) LIKE ? ESCAPE '\\') * {1.5 * w:.2f}"
637
+ f" + (LOWER(summary) LIKE ? ESCAPE '\\') * {0.5 * w:.2f}"
638
+ )
639
+ params.extend([like, like, like])
640
+ sql = (
641
+ "SELECT id, name, full_id, summary, info, syntax, parameters, remark, sql_score FROM ("
642
+ " SELECT id, name, full_id, summary, info, syntax, parameters, remark, "
643
+ f" ({' + '.join(score_terms)}) AS sql_score FROM revit_api"
644
+ ") WHERE sql_score > 0 "
645
+ "ORDER BY sql_score DESC, LENGTH(name) ASC, id ASC "
646
+ f"LIMIT {self._KEYWORD_CANDIDATES}"
647
+ )
648
+ conn = sqlite3.connect(self._api_db)
649
+ conn.row_factory = sqlite3.Row
650
+ try:
651
+ return conn.execute(sql, params).fetchall()
652
+ finally:
653
+ conn.close()
654
+
655
+ def _keyword_search_api(self, query: str, limit: int = 15) -> list[RetrievedItem]:
656
+ """Search API docs by keyword matching on name/full_id/summary.
657
+
658
+ 1. SQL ranks every row that matches any token by a coarse relevance
659
+ score and hands over the top _KEYWORD_CANDIDATES (see
660
+ _keyword_candidates).
661
+ 2. Python scoring ranks those by match quality (name > full_id >
662
+ summary, segment and exact-identifier bonuses) and penalizes long
663
+ compound names.
664
+ Token weights from _normalize_search_tokens boost entity nouns over verbs.
665
+ """
666
+ weighted_tokens = self._normalize_search_tokens(query)
667
+ if not weighted_tokens:
668
+ return []
669
+
670
+ tokens = [t for t, _ in weighted_tokens]
671
+ token_weights = {t: w for t, w in weighted_tokens}
672
+
673
+ all_rows = self._keyword_candidates(weighted_tokens)
674
+
675
+ # Score each row in Python (token weights boost entity nouns)
676
+ scored: list[tuple[float, dict]] = []
677
+ for r in all_rows:
678
+ name_l = (r["name"] or "").lower()
679
+ fid_l = (r["full_id"] or "").lower()
680
+ summary_l = (r["summary"] or "").lower()
681
+ score = 0.0
682
+
683
+ for token in tokens:
684
+ tw = token_weights.get(token, 0.5) # token importance weight
685
+ # Name match is most valuable
686
+ if token in name_l:
687
+ score += 5.0 * tw
688
+ # Bonus: token starts a name segment (Wall in "Wall.Create")
689
+ name_parts = re.split(r'[.\s(]', name_l)
690
+ if any(part == token or part.startswith(token) for part in name_parts):
691
+ score += 3.0 * tw
692
+ # Dotted tokens ("wall.create") never equal a segment; compare
693
+ # them with the identifier before the first space or "(" so
694
+ # Wall.Create and its overloads outrank Wall.CreateProfileSketch.
695
+ if "." in token:
696
+ head = re.split(r"[\s(]", name_l, maxsplit=1)[0]
697
+ if head == token:
698
+ score += 6.0 * tw
699
+ elif head.startswith(token):
700
+ score += 2.0 * tw
701
+ # full_id match (less weight since full_id often contains namespace)
702
+ elif token in fid_l:
703
+ score += 1.5 * tw
704
+ # summary match
705
+ if token in summary_l:
706
+ score += 0.5 * tw
707
+
708
+ if score <= 0:
709
+ continue
710
+
711
+ # Penalize long/deeply-nested names
712
+ name_len = len(r["name"] or "")
713
+ dot_count = (r["name"] or "").count(".")
714
+ if name_len > 80:
715
+ score *= 0.3
716
+ elif name_len > 60:
717
+ score *= 0.5
718
+ elif name_len > 40:
719
+ score *= 0.7
720
+ if dot_count >= 3:
721
+ score *= 0.5
722
+ # BuiltInFailures/BuiltInParameter are rarely the target API
723
+ if name_l.startswith("builtinfailures."):
724
+ score *= 0.3
725
+ elif name_l.startswith("builtinparameter."):
726
+ score *= 0.6
727
+
728
+ # Strong bonus for ALL tokens matching in name
729
+ name_matched = sum(1 for t in tokens if t in name_l)
730
+ if name_matched == len(tokens):
731
+ score *= 2.0 # all tokens in name — very relevant
732
+
733
+ scored.append((score, dict(r)))
734
+
735
+ scored.sort(key=lambda x: x[0], reverse=True)
736
+
737
+ items = []
738
+ for i, (sc, r) in enumerate(scored[:limit]):
739
+ items.append(RetrievedItem(
740
+ source="api",
741
+ chromadb_id=str(r["id"]),
742
+ distance=0.1 + i * 0.01, # low distance = high relevance
743
+ name=r["name"] or "",
744
+ full_id=r["full_id"] or "",
745
+ summary=r["summary"] or "",
746
+ info=r["info"] or "",
747
+ syntax=r["syntax"] or "",
748
+ parameters=r["parameters"] or "",
749
+ remark=r["remark"] or "",
750
+ ))
751
+ self._log.info(f"[keyword_search] query={query!r} "
752
+ f"tokens={[(t, f'{w:.1f}') for t, w in weighted_tokens]} "
753
+ f"found={len(items)} "
754
+ f"top3={[(s[1]['name'][:50], f'{s[0]:.1f}') for s in scored[:3]]}")
755
+ return items
756
+
757
+ def _keyword_search_sdk(self, query: str, limit: int = 3) -> list[RetrievedItem]:
758
+ """Keyword fallback for SDK samples when no embedding key is configured.
759
+
760
+ Matches tokens against project name, summary and mentioned_apis; a hit on
761
+ the project name or the API list outranks one in the prose summary.
762
+ Only the current sdk_info schema is supported (the legacy table has no
763
+ API list to match against).
764
+ """
765
+ if not self._sdk_new_schema:
766
+ return []
767
+ weighted_tokens = self._normalize_search_tokens(query)
768
+ if not weighted_tokens:
769
+ return []
770
+ tokens = [t for t, _ in weighted_tokens]
771
+ token_weights = {t: w for t, w in weighted_tokens}
772
+
773
+ conditions = []
774
+ params: list[str] = []
775
+ for token in tokens:
776
+ like = f"%{_escape_like(token)}%"
777
+ conditions.append(
778
+ "(LOWER(project) LIKE ? ESCAPE '\\' OR LOWER(summary) LIKE ? ESCAPE '\\' "
779
+ "OR LOWER(mentioned_apis) LIKE ? ESCAPE '\\')"
780
+ )
781
+ params.extend([like, like, like])
782
+ conn = sqlite3.connect(self._sdk_db)
783
+ conn.row_factory = sqlite3.Row
784
+ rows = conn.execute(
785
+ f"SELECT id, project, summary, content, mentioned_apis FROM sdk_info "
786
+ f"WHERE {' OR '.join(conditions)} LIMIT 200",
787
+ params,
788
+ ).fetchall()
789
+ conn.close()
790
+
791
+ scored: list[tuple[float, sqlite3.Row]] = []
792
+ for r in rows:
793
+ project_l = (r["project"] or "").lower()
794
+ summary_l = (r["summary"] or "").lower()
795
+ apis_l = (r["mentioned_apis"] or "").lower()
796
+ score = 0.0
797
+ for token in tokens:
798
+ tw = token_weights.get(token, 0.5)
799
+ if token in project_l:
800
+ score += 5.0 * tw
801
+ if token in apis_l:
802
+ score += 3.0 * tw
803
+ if token in summary_l:
804
+ score += 1.0 * tw
805
+ if score > 0:
806
+ scored.append((score, r))
807
+ scored.sort(key=lambda x: x[0], reverse=True)
808
+
809
+ items = []
810
+ for i, (_, r) in enumerate(scored[:limit]):
811
+ items.append(RetrievedItem(
812
+ source="sdk",
813
+ chromadb_id=str(r["id"]),
814
+ distance=0.1 + i * 0.01,
815
+ project=r["project"] or "",
816
+ summary=r["summary"] or "",
817
+ content=r["content"] or "",
818
+ mentioned_apis=r["mentioned_apis"] or "",
819
+ ))
820
+ self._log.info(f"[keyword_search_sdk] query={query!r} found={len(items)}")
821
+ return items
822
+
823
+ # ------------------------------------------------------------------
824
+ # Context assembly
825
+ # ------------------------------------------------------------------
826
+
827
+ def build_context(
828
+ self,
829
+ results: SearchResults,
830
+ api_max_chars: int = 600,
831
+ code_max_chars: int = 2000,
832
+ ) -> dict[str, str]:
833
+ """
834
+ Assemble structured context strings for LLM prompt injection.
835
+
836
+ Returns:
837
+ {"api_context": str, "code_context": str}
838
+ """
839
+ api_parts = []
840
+ for item in results.api_items:
841
+ header = item.full_id or item.name
842
+ body_parts = []
843
+ if item.summary:
844
+ body_parts.append(item.summary)
845
+ if item.syntax:
846
+ body_parts.append(f"Syntax: {item.syntax}")
847
+ if item.parameters:
848
+ body_parts.append(f"Parameters: {item.parameters}")
849
+ if item.remark:
850
+ body_parts.append(f"Remarks: {item.remark}")
851
+ if not body_parts and item.info:
852
+ body_parts.append(item.info)
853
+ body = "\n".join(body_parts)[:api_max_chars]
854
+ api_parts.append(f"### {header}\n{body}")
855
+
856
+ code_parts = []
857
+ for item in results.sdk_items:
858
+ header = f"Project: {item.project}"
859
+ if item.mentioned_apis:
860
+ header += f" | APIs: {item.mentioned_apis}"
861
+ body = item.content[:code_max_chars] if item.content else item.summary
862
+ code_parts.append(f"// {header}\n{body}")
863
+
864
+ return {
865
+ "api_context": "\n\n".join(api_parts),
866
+ "code_context": "\n\n".join(code_parts),
867
+ }
868
+
869
+ # ------------------------------------------------------------------
870
+ # Pretty-print
871
+ # ------------------------------------------------------------------
872
+
873
+ def format_results(self, results: SearchResults) -> str:
874
+ """Format search results for display."""
875
+ lines = []
876
+ if results.rewritten_query and results.rewritten_query != results.query:
877
+ lines.append(f"query: {results.query}")
878
+ lines.append(f"rewritten: {results.rewritten_query}")
879
+ lines.append("")
880
+ lines.append("=" * 60)
881
+ lines.append("API results:")
882
+ lines.append("=" * 60)
883
+ for i, item in enumerate(results.api_items, 1):
884
+ sim = 1 - item.distance
885
+ lines.append(f"\n[{i}] similarity: {sim:.4f}")
886
+ lines.append(f" name: {item.full_id or item.name}")
887
+ lines.append(f" summary: {item.summary[:150]}...")
888
+
889
+ lines.append("\n" + "=" * 60)
890
+ lines.append("SDK code results:")
891
+ lines.append("=" * 60)
892
+ for i, item in enumerate(results.sdk_items, 1):
893
+ sim = 1 - item.distance
894
+ lines.append(f"\n[{i}] similarity: {sim:.4f}")
895
+ lines.append(f" project: {item.project}")
896
+ lines.append(f" summary: {item.summary[:150]}...")
897
+ if item.content:
898
+ preview = item.content[:200].replace("\n", "\n ")
899
+ lines.append(f" code: {preview}...")
900
+
901
+ return "\n".join(lines)