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,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__"]
|
revit_api_docs/config.py
ADDED
|
@@ -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
|