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.
- revit_api_docs/__init__.py +18 -0
- revit_api_docs/__main__.py +8 -0
- revit_api_docs/config.py +115 -0
- revit_api_docs/data.py +320 -0
- revit_api_docs/default_config.yaml +85 -0
- revit_api_docs/embedder/__init__.py +1 -0
- revit_api_docs/embedder/providers/__init__.py +25 -0
- revit_api_docs/embedder/providers/base.py +28 -0
- revit_api_docs/embedder/providers/google.py +36 -0
- revit_api_docs/embedder/providers/openai.py +58 -0
- revit_api_docs/llm_client.py +288 -0
- revit_api_docs/prompts/__init__.py +5 -0
- revit_api_docs/prompts/loader.py +20 -0
- revit_api_docs/prompts/pipeline.api_quality_stage1_no_html.md +28 -0
- revit_api_docs/prompts/pipeline.api_quality_stage1_system.md +1 -0
- revit_api_docs/prompts/pipeline.api_quality_stage1_with_html.md +35 -0
- revit_api_docs/prompts/pipeline.api_quality_stage2.md +33 -0
- revit_api_docs/prompts/pipeline.api_quality_stage2_system.md +1 -0
- revit_api_docs/prompts/pipeline.rewrite_query.md +21 -0
- revit_api_docs/prompts/pipeline.rewrite_query_system.md +1 -0
- revit_api_docs/prompts/pipeline.sdk_extract_readme_system.md +1 -0
- revit_api_docs/prompts/pipeline.sdk_extract_readme_user.md +22 -0
- revit_api_docs/prompts/pipeline.sdk_golden_code.md +32 -0
- revit_api_docs/prompts/pipeline.sdk_golden_code_system.md +1 -0
- revit_api_docs/prompts/pipeline.sdk_quality_file_batch.md +17 -0
- revit_api_docs/prompts/pipeline.sdk_quality_file_system.md +1 -0
- revit_api_docs/prompts/pipeline.sdk_quality_project.md +20 -0
- revit_api_docs/prompts/pipeline.sdk_quality_project_system.md +1 -0
- revit_api_docs/reranker.py +177 -0
- revit_api_docs/retriever.py +901 -0
- revit_api_docs/server.py +244 -0
- revit_api_docs-0.1.0.dist-info/METADATA +139 -0
- revit_api_docs-0.1.0.dist-info/RECORD +36 -0
- revit_api_docs-0.1.0.dist-info/WHEEL +4 -0
- revit_api_docs-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|