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,18 @@
1
+ """revit-api-docs: Revit API reference search for MCP hosts.
2
+
3
+ An optional companion to revit-bridge for models that do not know the Revit
4
+ API well. It answers two questions over a local copy of the Revit 2026 API
5
+ reference (27,596 entries) and the SDK samples: "which API do I call?" and
6
+ "how does the SDK use it?". The data set is downloaded from a GitHub Release
7
+ on first run; the package never talks to Revit.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from importlib.metadata import PackageNotFoundError, version
12
+
13
+ try:
14
+ __version__ = version("revit-api-docs")
15
+ except PackageNotFoundError: # running from a source tree without install
16
+ __version__ = "0.0.0"
17
+
18
+ __all__ = ["__version__"]
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from revit_api_docs.server import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,115 @@
1
+ """Configuration: bundled defaults plus environment overrides.
2
+
3
+ The MCP server needs no config file. `load_config()` reads the packaged
4
+ `default_config.yaml` (or the file named by `REVIT_API_DOCS_CONFIG`) and then
5
+ applies the `REVIT_API_DOCS_*` environment variables documented in the README.
6
+ The dict keeps the shape the retriever and the pipeline tools expect
7
+ (`embedding.models.<provider>`, `llm.models.<provider>`, `rerank`, `retrieval`,
8
+ `sdk`); API keys are looked up by the environment variable named in
9
+ `api_key_env`, never stored in the dict.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import yaml
18
+
19
+ ENV_CONFIG = "REVIT_API_DOCS_CONFIG"
20
+ ENV_EMBEDDING_API_KEY = "REVIT_API_DOCS_EMBEDDING_API_KEY"
21
+ ENV_EMBEDDING_BASE_URL = "REVIT_API_DOCS_EMBEDDING_BASE_URL"
22
+ ENV_EMBEDDING_MODEL = "REVIT_API_DOCS_EMBEDDING_MODEL"
23
+ ENV_EMBEDDING_PROVIDER = "REVIT_API_DOCS_EMBEDDING_PROVIDER"
24
+
25
+ # Keys tried, in order, when REVIT_API_DOCS_EMBEDDING_API_KEY is unset.
26
+ EMBEDDING_KEY_FALLBACKS = ("OPENROUTER_API_KEY",)
27
+
28
+ _DEFAULT_CONFIG = Path(__file__).with_name("default_config.yaml")
29
+
30
+
31
+ def load_config(config_path: str | os.PathLike[str] | None = None) -> dict[str, Any]:
32
+ """Load the YAML config and apply environment overrides."""
33
+ path = Path(config_path or os.getenv(ENV_CONFIG) or _DEFAULT_CONFIG)
34
+ with open(path, "r", encoding="utf-8") as f:
35
+ config = yaml.safe_load(f) or {}
36
+ apply_env_overrides(config, os.environ)
37
+ return config
38
+
39
+
40
+ def apply_env_overrides(config: dict[str, Any], env: os._Environ[str] | dict[str, str]) -> None:
41
+ """Mutate `config` in place from REVIT_API_DOCS_* variables."""
42
+ emb = config.setdefault("embedding", {})
43
+ provider = env.get(ENV_EMBEDDING_PROVIDER) or emb.get("provider", "openai")
44
+ emb["provider"] = provider
45
+ model_cfg = emb.setdefault("models", {}).setdefault(provider, {})
46
+
47
+ if env.get(ENV_EMBEDDING_MODEL):
48
+ model_cfg["model"] = env[ENV_EMBEDDING_MODEL]
49
+ if env.get(ENV_EMBEDDING_BASE_URL) and provider == "openai":
50
+ model_cfg["base_url"] = env[ENV_EMBEDDING_BASE_URL]
51
+
52
+ # Resolve which variable holds the embedding key so the provider can read it.
53
+ if provider == "openai":
54
+ if env.get(ENV_EMBEDDING_API_KEY):
55
+ model_cfg["api_key_env"] = ENV_EMBEDDING_API_KEY
56
+ else:
57
+ for name in EMBEDDING_KEY_FALLBACKS:
58
+ if env.get(name):
59
+ model_cfg["api_key_env"] = name
60
+ break
61
+ else:
62
+ model_cfg.setdefault("api_key_env", ENV_EMBEDDING_API_KEY)
63
+
64
+
65
+ def load_dotenv(root: str | Path | None = None) -> None:
66
+ """Read `.env` in `root` (default: current directory) into os.environ.
67
+
68
+ Existing variables are not overwritten. Used by the pipeline tools; the
69
+ MCP server relies on the host's environment only.
70
+ """
71
+ env = Path(root or Path.cwd()) / ".env"
72
+ if not env.exists():
73
+ return
74
+ for line in env.read_text(encoding="utf-8", errors="ignore").splitlines():
75
+ line = line.strip()
76
+ if not line or line.startswith("#") or "=" not in line:
77
+ continue
78
+ k, v = line.split("=", 1)
79
+ os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
80
+
81
+
82
+ def get_api_key(env_name: str) -> str:
83
+ """Return the key held by `env_name`, or raise ValueError when unset."""
84
+ key = os.getenv(env_name, "")
85
+ if not key:
86
+ raise ValueError(f"environment variable {env_name} is not set")
87
+ return key
88
+
89
+
90
+ _config: dict[str, Any] | None = None
91
+
92
+
93
+ def get_config() -> dict[str, Any]:
94
+ """Process-wide config singleton (first call loads it)."""
95
+ global _config
96
+ if _config is None:
97
+ _config = load_config()
98
+ return _config
99
+
100
+
101
+ def get_proxy_url() -> str | None:
102
+ """Proxy URL from HTTPS_PROXY/HTTP_PROXY, else from the config, else None."""
103
+ from_env = (
104
+ os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY")
105
+ or os.getenv("https_proxy") or os.getenv("http_proxy")
106
+ )
107
+ if from_env:
108
+ return from_env
109
+ try:
110
+ proxy_cfg = get_config().get("proxy", {})
111
+ except FileNotFoundError:
112
+ return None
113
+ if proxy_cfg.get("enabled"):
114
+ return proxy_cfg.get("https") or proxy_cfg.get("http") or None
115
+ return None
revit_api_docs/data.py ADDED
@@ -0,0 +1,320 @@
1
+ """Data set location and first-run download.
2
+
3
+ The API reference (SQLite) and the vector stores (ChromaDB directories) are
4
+ not in the wheel; they come from the GitHub Release `v1.0-data` of this
5
+ repository. `ensure_data()` downloads whatever is missing into the data
6
+ directory, verifies each file against the SHA-256 recorded below, unpacks the
7
+ tarballs and writes `manifest.json`.
8
+
9
+ Data directory (override with REVIT_API_DOCS_DATA_DIR):
10
+ Windows %LOCALAPPDATA%/revit-api-docs
11
+ macOS ~/Library/Application Support/revit-api-docs
12
+ Linux $XDG_DATA_HOME/revit-api-docs (default ~/.local/share)
13
+
14
+ Layout inside it (same as the old repository's data/ tree, so an existing
15
+ copy can be pointed at directly):
16
+ sqlite/revit_api.db
17
+ sqlite/revit_sdk.db
18
+ chromadb/chromadb_api/
19
+ chromadb/chromadb_code/
20
+ manifest.json
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ import logging
27
+ import os
28
+ import shutil
29
+ import sys
30
+ import tarfile
31
+ import tempfile
32
+ import time
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+ from typing import Callable, Iterable
36
+
37
+ import httpx
38
+
39
+ log = logging.getLogger("revit_api_docs.data")
40
+
41
+ ENV_DATA_DIR = "REVIT_API_DOCS_DATA_DIR"
42
+ ENV_RELEASE_URL = "REVIT_API_DOCS_RELEASE_URL"
43
+
44
+ RELEASE_TAG = "v1.0-data"
45
+ RELEASE_URL = f"https://github.com/revitbridge/revit-api-docs/releases/download/{RELEASE_TAG}"
46
+
47
+ ProgressFn = Callable[[str, int, int], None]
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Artifact:
52
+ """One release asset: where it lands and how to check it."""
53
+
54
+ name: str
55
+ sha256: str
56
+ size: int
57
+ target: str # path inside the data dir (file, or directory for tarballs)
58
+ extract: bool = False
59
+
60
+
61
+ ARTIFACTS: tuple[Artifact, ...] = (
62
+ Artifact("revit_api.db", "38f78297b11b5fb4aa8a0c609e2a0535b5d2f6770ea895c8468742e6d4a7bf72",
63
+ 33_611_776, "sqlite/revit_api.db"),
64
+ Artifact("revit_sdk.db", "f381f392485cd4910b2b826062414b5331c29f165269412ebb59569dd176e474",
65
+ 954_368, "sqlite/revit_sdk.db"),
66
+ Artifact("chromadb_api.tar.gz", "2db7c2fafa45defb1cc56522c5c5455741549d42d56bbec918a93554c4b15c73",
67
+ 299_124_932, "chromadb/chromadb_api", extract=True),
68
+ Artifact("chromadb_code.tar.gz", "3ee38a4e44e4b41154faeafd431c830ca54275d2738897971e5780cb493118ad",
69
+ 1_792_184, "chromadb/chromadb_code", extract=True),
70
+ )
71
+
72
+ TOTAL_DOWNLOAD_BYTES = sum(a.size for a in ARTIFACTS)
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class DataPaths:
77
+ root: Path
78
+ api_db: Path
79
+ sdk_db: Path
80
+ chromadb_api: Path
81
+ chromadb_code: Path
82
+ manifest: Path
83
+
84
+
85
+ def default_data_dir() -> Path:
86
+ if sys.platform == "win32":
87
+ base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
88
+ elif sys.platform == "darwin":
89
+ base = str(Path.home() / "Library" / "Application Support")
90
+ else:
91
+ base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
92
+ return Path(base) / "revit-api-docs"
93
+
94
+
95
+ def data_dir() -> Path:
96
+ env = os.environ.get(ENV_DATA_DIR)
97
+ return Path(env).expanduser() if env else default_data_dir()
98
+
99
+
100
+ def release_url() -> str:
101
+ return os.environ.get(ENV_RELEASE_URL, RELEASE_URL).rstrip("/")
102
+
103
+
104
+ def paths(root: Path | None = None) -> DataPaths:
105
+ root = Path(root) if root is not None else data_dir()
106
+ return DataPaths(
107
+ root=root,
108
+ api_db=root / "sqlite" / "revit_api.db",
109
+ sdk_db=root / "sqlite" / "revit_sdk.db",
110
+ chromadb_api=root / "chromadb" / "chromadb_api",
111
+ chromadb_code=root / "chromadb" / "chromadb_code",
112
+ manifest=root / "manifest.json",
113
+ )
114
+
115
+
116
+ def _installed(root: Path, art: Artifact) -> bool:
117
+ target = root / art.target
118
+ if art.extract:
119
+ return (target / "chroma.sqlite3").is_file()
120
+ return target.is_file()
121
+
122
+
123
+ def unexpected_sizes(root: Path) -> list[tuple[Artifact, int]]:
124
+ """Present files whose size differs from the release asset (hand-copied
125
+ or built locally). They are used as they are, never replaced."""
126
+ out = []
127
+ for art in ARTIFACTS:
128
+ if art.extract:
129
+ continue
130
+ target = root / art.target
131
+ if target.is_file():
132
+ size = target.stat().st_size
133
+ if size != art.size:
134
+ out.append((art, size))
135
+ return out
136
+
137
+
138
+ def read_manifest(root: Path) -> dict | None:
139
+ p = root / "manifest.json"
140
+ if not p.is_file():
141
+ return None
142
+ try:
143
+ return json.loads(p.read_text(encoding="utf-8"))
144
+ except (OSError, ValueError):
145
+ return None
146
+
147
+
148
+ def missing(root: Path | None = None) -> list[Artifact]:
149
+ """Artifacts that must be downloaded: the absent ones.
150
+
151
+ An existing file is never replaced, whatever its size (a hand-copied tree
152
+ may hold a different build; see unexpected_sizes). The one exception is a
153
+ manifest written by this installer for another release, which marks the
154
+ whole tree stale.
155
+ """
156
+ root = Path(root) if root is not None else data_dir()
157
+ manifest = read_manifest(root)
158
+ if manifest is not None and manifest.get("release") != RELEASE_TAG:
159
+ return list(ARTIFACTS)
160
+ return [a for a in ARTIFACTS if not _installed(root, a)]
161
+
162
+
163
+ def is_ready(root: Path | None = None) -> bool:
164
+ return not missing(root)
165
+
166
+
167
+ def sha256_file(path: Path, chunk: int = 1 << 20) -> str:
168
+ h = hashlib.sha256()
169
+ with open(path, "rb") as f:
170
+ while True:
171
+ block = f.read(chunk)
172
+ if not block:
173
+ break
174
+ h.update(block)
175
+ return h.hexdigest()
176
+
177
+
178
+ def download_file(url: str, dest: Path, expected_size: int,
179
+ progress: ProgressFn | None = None, name: str = "",
180
+ timeout: float = 60.0) -> None:
181
+ """Stream `url` to `dest`, resuming a partial `dest` with a Range request."""
182
+ dest.parent.mkdir(parents=True, exist_ok=True)
183
+ have = dest.stat().st_size if dest.exists() else 0
184
+ if have > expected_size:
185
+ dest.unlink()
186
+ have = 0
187
+ if have == expected_size:
188
+ return # a previous run finished the download but not the checks
189
+ headers = {"Range": f"bytes={have}-"} if have else {}
190
+ mode = "ab" if have else "wb"
191
+
192
+ with httpx.Client(follow_redirects=True, timeout=timeout) as client:
193
+ with client.stream("GET", url, headers=headers) as resp:
194
+ if have and resp.status_code == 200:
195
+ # Server ignored the range: start over.
196
+ have, mode = 0, "wb"
197
+ elif resp.status_code not in (200, 206):
198
+ resp.raise_for_status()
199
+ done = have
200
+ last_report = time.monotonic()
201
+ with open(dest, mode) as out:
202
+ for chunk in resp.iter_bytes(1 << 20):
203
+ out.write(chunk)
204
+ done += len(chunk)
205
+ now = time.monotonic()
206
+ if progress and (now - last_report >= 1.0 or done >= expected_size):
207
+ progress(name or dest.name, done, expected_size)
208
+ last_report = now
209
+ if progress:
210
+ progress(name or dest.name, dest.stat().st_size, expected_size)
211
+
212
+
213
+ def _extract_tarball(archive: Path, target: Path) -> None:
214
+ """Unpack `archive` (which holds one top-level directory) to `target`."""
215
+ target.parent.mkdir(parents=True, exist_ok=True)
216
+ tmp = Path(tempfile.mkdtemp(prefix=f".{target.name}-", dir=target.parent))
217
+ try:
218
+ with tarfile.open(archive, "r:gz") as tar:
219
+ try:
220
+ tar.extractall(tmp, filter="data")
221
+ except TypeError: # Python < 3.11.4 without the filter argument
222
+ tar.extractall(tmp)
223
+ entries = [p for p in tmp.iterdir()]
224
+ src = entries[0] if len(entries) == 1 and entries[0].is_dir() else tmp
225
+ if target.exists():
226
+ shutil.rmtree(target)
227
+ shutil.move(str(src), str(target))
228
+ finally:
229
+ shutil.rmtree(tmp, ignore_errors=True)
230
+
231
+
232
+ def install_artifact(art: Artifact, root: Path, progress: ProgressFn | None = None) -> None:
233
+ """Download one artifact, verify it and put it in place."""
234
+ url = f"{release_url()}/{art.name}"
235
+ part = root / "downloads" / f"{art.name}.part"
236
+ log.info("downloading %s (%.1f MB) from %s", art.name, art.size / 1e6, url)
237
+ download_file(url, part, art.size, progress=progress, name=art.name)
238
+
239
+ digest = sha256_file(part)
240
+ if digest != art.sha256:
241
+ part.unlink(missing_ok=True)
242
+ raise RuntimeError(
243
+ f"{art.name}: SHA-256 mismatch (got {digest[:16]}..., expected "
244
+ f"{art.sha256[:16]}...); the partial download was discarded, run again"
245
+ )
246
+
247
+ target = root / art.target
248
+ if art.extract:
249
+ _extract_tarball(part, target)
250
+ part.unlink(missing_ok=True)
251
+ else:
252
+ target.parent.mkdir(parents=True, exist_ok=True)
253
+ os.replace(part, target)
254
+
255
+
256
+ def describe_artifact(root: Path, art: Artifact, installed: bool) -> dict:
257
+ """Manifest entry for one artifact, honest about what was checked.
258
+
259
+ `installed` means this run downloaded and hash-checked it. Anything else
260
+ was already in `root`: a file is hashed now and counts as verified only
261
+ when it matches the release; an unpacked directory has nothing to hash
262
+ against and is recorded as trusted.
263
+ """
264
+ entry: dict = {"target": art.target, "release_sha256": art.sha256, "release_size": art.size}
265
+ target = root / art.target
266
+ if installed:
267
+ entry.update(sha256=art.sha256, size=art.size, verified=True)
268
+ elif art.extract:
269
+ entry.update(verified=False, trusted=(target / "chroma.sqlite3").is_file())
270
+ elif target.is_file():
271
+ digest = sha256_file(target)
272
+ verified = digest == art.sha256
273
+ entry.update(sha256=digest, size=target.stat().st_size, verified=verified)
274
+ if not verified:
275
+ entry["trusted"] = True
276
+ else:
277
+ entry.update(verified=False, trusted=False)
278
+ return entry
279
+
280
+
281
+ def write_manifest(root: Path, installed: Iterable[str] = ()) -> None:
282
+ """Record the tree under `root`; `installed` names the artifacts this run
283
+ downloaded and verified (see describe_artifact for the rest)."""
284
+ root.mkdir(parents=True, exist_ok=True)
285
+ done = set(installed)
286
+ manifest = {
287
+ "release": RELEASE_TAG,
288
+ "source": release_url(),
289
+ "installed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
290
+ "files": {a.name: describe_artifact(root, a, a.name in done) for a in ARTIFACTS},
291
+ }
292
+ (root / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
293
+
294
+
295
+ def ensure_data(root: Path | None = None, progress: ProgressFn | None = None) -> DataPaths:
296
+ """Make sure every artifact is installed under `root`; return its paths."""
297
+ p = paths(root)
298
+ todo = missing(p.root)
299
+ for art, size in unexpected_sizes(p.root):
300
+ log.warning("%s: existing file is %d bytes, the %s release file is %d; using it as is "
301
+ "(delete it to re-download)", p.root / art.target, size, RELEASE_TAG, art.size)
302
+ if todo:
303
+ p.root.mkdir(parents=True, exist_ok=True)
304
+ for art in todo:
305
+ install_artifact(art, p.root, progress=progress)
306
+ write_manifest(p.root, installed=[a.name for a in todo])
307
+ trusted = [n for n, e in read_manifest(p.root)["files"].items() if e.get("trusted")]
308
+ if trusted:
309
+ log.warning("manifest written with unverified pre-existing files: %s", ", ".join(trusted))
310
+ shutil.rmtree(p.root / "downloads", ignore_errors=True)
311
+ elif read_manifest(p.root) is None:
312
+ log.info("using existing data in %s (no manifest)", p.root)
313
+ return p
314
+
315
+
316
+ def stderr_progress(name: str, done: int, total: int) -> None:
317
+ """Default progress reporter: one line per step, never on stdout."""
318
+ pct = 100 * done // total if total else 0
319
+ print(f"revit-api-docs: {name} {done / 1e6:.1f}/{total / 1e6:.1f} MB ({pct}%)",
320
+ file=sys.stderr, flush=True)
@@ -0,0 +1,85 @@
1
+ # Bundled defaults for revit-api-docs.
2
+ #
3
+ # The MCP server needs no config file: everything a user may change comes from
4
+ # environment variables (see README, "Configure"), which config.py layers on
5
+ # top of this file. The offline pipeline tools (pipeline/ in the repository)
6
+ # may point REVIT_API_DOCS_CONFIG at a copy of this file with edited llm/sdk
7
+ # sections. API keys are never written here, only the environment variable
8
+ # that holds them.
9
+
10
+ revit_version: "2026"
11
+
12
+ # HTTP proxy for outbound API calls (HTTPS_PROXY / HTTP_PROXY take precedence).
13
+ proxy:
14
+ enabled: false
15
+ http: ""
16
+ https: ""
17
+
18
+ openrouter:
19
+ base_url: "https://openrouter.ai/api/v1"
20
+ api_key_env: "OPENROUTER_API_KEY"
21
+
22
+ # --- LLM (pipeline tools and optional query rewriting only) ---
23
+ llm:
24
+ provider: "claude"
25
+ models:
26
+ claude:
27
+ model: "anthropic/claude-sonnet-4.6"
28
+ api_key_env: "OPENROUTER_API_KEY"
29
+ base_url: "https://openrouter.ai/api/v1"
30
+ gemini:
31
+ model: "google/gemini-3-flash-preview"
32
+ api_key_env: "OPENROUTER_API_KEY"
33
+ base_url: "https://openrouter.ai/api/v1"
34
+ gemini_flash:
35
+ model: "google/gemini-3-flash-preview"
36
+ api_key_env: "OPENROUTER_API_KEY"
37
+ base_url: "https://openrouter.ai/api/v1"
38
+ deepseek:
39
+ model: "deepseek/deepseek-chat-v3-0324"
40
+ api_key_env: "OPENROUTER_API_KEY"
41
+ base_url: "https://openrouter.ai/api/v1"
42
+ temperature: 0.3
43
+ max_tokens: 16384
44
+
45
+ # --- Embedding (query time) ---
46
+ # The shipped vector store was built with openai/text-embedding-3-large
47
+ # (3072 dimensions) through OpenRouter. Queries must use the same model or
48
+ # vector search returns noise; keyword search works without any key.
49
+ embedding:
50
+ provider: "openai"
51
+ models:
52
+ openai:
53
+ model: "openai/text-embedding-3-large"
54
+ dimension: 3072
55
+ api_key_env: "REVIT_API_DOCS_EMBEDDING_API_KEY"
56
+ base_url: "https://openrouter.ai/api/v1"
57
+ google:
58
+ model: "text-embedding-004"
59
+ dimension: 768
60
+ api_key_env: "GOOGLE_API_KEY"
61
+
62
+ # --- Rerank (optional; Cohere native REST API, skipped without the key) ---
63
+ rerank:
64
+ enabled: true
65
+ provider: "cohere"
66
+ models:
67
+ cohere:
68
+ model: "rerank-v3.5"
69
+ api_key_env: "COHERE_API_KEY"
70
+
71
+ # --- Retrieval ---
72
+ retrieval:
73
+ api:
74
+ top_k: 30 # candidates fetched before rerank
75
+ rerank_top_n: 15 # default number returned
76
+ code:
77
+ top_k: 5
78
+ rerank_top_n: 3
79
+
80
+ # --- SDK sample extraction (pipeline/sdk_parser) ---
81
+ sdk:
82
+ stage1_provider: "gemini_flash" # README parsing (cheap + fast)
83
+ stage2_provider: "claude" # golden code generation (quality)
84
+ num_workers_phase1: 10
85
+ num_workers_phase2: 5
@@ -0,0 +1 @@
1
+ """Query-time embedding providers (the offline batch embedder lives in pipeline/)."""
@@ -0,0 +1,25 @@
1
+ """Embedding provider factory: builds the provider named in config["embedding"]."""
2
+ from .base import BaseEmbedding
3
+
4
+
5
+ def create_embedding(config: dict) -> BaseEmbedding:
6
+ """Create the embedding provider selected by the config."""
7
+ provider = config["embedding"]["provider"]
8
+ model_config = config["embedding"]["models"][provider]
9
+
10
+ if provider == "google":
11
+ from .google import GoogleEmbedding
12
+ return GoogleEmbedding(**model_config)
13
+
14
+ elif provider == "openai":
15
+ from .openai import OpenAIEmbedding
16
+ return OpenAIEmbedding(**model_config)
17
+
18
+ elif provider == "local_hf":
19
+ raise NotImplementedError("local HuggingFace embedding is not implemented")
20
+
21
+ elif provider == "zhipu":
22
+ raise NotImplementedError("zhipu embedding is not implemented")
23
+
24
+ else:
25
+ raise ValueError(f"unsupported embedding provider: {provider}")
@@ -0,0 +1,28 @@
1
+ """Abstract base for embedding providers (OpenAI-compatible, Google)."""
2
+ from abc import ABC, abstractmethod
3
+
4
+
5
+ class BaseEmbedding(ABC):
6
+ """Uniform embedding interface."""
7
+
8
+ @property
9
+ @abstractmethod
10
+ def model_name(self) -> str:
11
+ """Model name, recorded in the index meta.json."""
12
+ pass
13
+
14
+ @property
15
+ @abstractmethod
16
+ def dimension(self) -> int:
17
+ """Vector dimension."""
18
+ pass
19
+
20
+ @abstractmethod
21
+ def embed_texts(self, texts: list[str]) -> list[list[float]]:
22
+ """Embed a batch of texts."""
23
+ pass
24
+
25
+ @abstractmethod
26
+ def embed_query(self, query: str) -> list[float]:
27
+ """Embed a single query."""
28
+ pass
@@ -0,0 +1,36 @@
1
+ """Google embedding provider (google-genai SDK; optional `pipeline` extra)."""
2
+ from .base import BaseEmbedding
3
+ from ...config import get_api_key
4
+
5
+
6
+ class GoogleEmbedding(BaseEmbedding):
7
+
8
+ def __init__(self, model: str = "text-embedding-004", dimension: int = 768, api_key_env: str = "GOOGLE_API_KEY"):
9
+ from google import genai
10
+ self._model = model
11
+ self._dimension = dimension
12
+ self._client = genai.Client(api_key=get_api_key(api_key_env))
13
+
14
+ @property
15
+ def model_name(self) -> str:
16
+ return self._model
17
+
18
+ @property
19
+ def dimension(self) -> int:
20
+ return self._dimension
21
+
22
+ def embed_texts(self, texts: list[str]) -> list[list[float]]:
23
+ """Batch embedding (the Google API accepts batches)."""
24
+ result = self._client.models.embed_content(
25
+ model=self._model,
26
+ contents=texts,
27
+ )
28
+ return [e.values for e in result.embeddings]
29
+
30
+ def embed_query(self, query: str) -> list[float]:
31
+ """Embed a single query."""
32
+ result = self._client.models.embed_content(
33
+ model=self._model,
34
+ contents=[query],
35
+ )
36
+ return result.embeddings[0].values