pi-revit 0.2.9 → 0.2.10

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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ Format follows [Keep a Changelog](https://keepachangelog.com/); version headers
7
7
  Every published version gets an entry with **Added** / **Changed** / **Fixed** sections
8
8
  describing what the user will notice — not internal refactors.
9
9
 
10
+ ## [0.2.10] - 2026-07-22
11
+
12
+ ### Fixed
13
+ - `search_api_docs`: constructor overloads can now be targeted with the natural C#
14
+ spelling — `FilteredElementCollector(Document` matches even though constructors are
15
+ rendered as `new FilteredElementCollector(Document)`. Previously only the `new `-prefixed
16
+ form matched (the same typography-cliff family as the 0.2.8 comma-spacing fix).
17
+
18
+ ### Added
19
+ - `scripts/benchmark-search-docs.py`: a reproducible ~630-query benchmark of
20
+ `search_api_docs` scored against Autodesk's own RevitAPI.xml — exact-name recall,
21
+ signature/spacing variants, hard syntax, namespace ambiguity, adversarial honesty
22
+ controls, documentation fidelity, and latency percentiles. Run it against any Revit
23
+ version with the bridge loaded. Measured on Revit 2025 at 0.2.9: 100% recall / 98.8%
24
+ top-1 on exact names, 100% spacing-variant agreement, 0 false positives across 99
25
+ adversarial mutations, 50/50 correct parameter docs, p50 8 ms.
26
+
27
+ Requires redeploying the Revit add-in (`scripts\deploy.ps1` with Revit closed, then
28
+ restart Revit).
29
+
10
30
  ## [0.2.9] - 2026-07-21
11
31
 
12
32
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-revit",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "Native Pi connector for Autodesk Revit. Run npx.cmd -y pi-revit for the full Windows install.",
5
5
  "author": "Ahmad Altahlawi",
6
6
  "license": "MIT",
@@ -0,0 +1,304 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Reproducible benchmark of pi-revit search_api_docs against RevitAPI.xml ground truth.
3
+
4
+ Usage: python scripts/benchmark-search-docs.py [results.json] [path-to-RevitAPI.xml]
5
+ Requires: Revit running with the pi-revit bridge add-in loaded (a document need not
6
+ be open) and Python 3.8+. Ground truth is Autodesk's own RevitAPI.xml, so every
7
+ number is reproducible on any machine with the same Revit version.
8
+
9
+ Categories:
10
+ A exact-name recall/ranking B signature + spacing variants
11
+ C complex syntax (arrays, nested generics, ref/out, ctors)
12
+ D namespace ambiguity E honesty/adversarial controls
13
+ F documentation fidelity G latency stats
14
+ """
15
+ import glob, json, os, random, re, sys, time, urllib.request, xml.etree.ElementTree as ET
16
+ from collections import defaultdict
17
+
18
+ random.seed(20260722) # reproducible sampling
19
+
20
+ APPDATA = os.environ.get("APPDATA") or os.path.expanduser(r"~\AppData\Roaming")
21
+ BRIDGE = json.load(open(os.path.join(APPDATA, "RevitBridge", "bridge.json")))
22
+ BASE, TOKEN = BRIDGE["baseUrl"], BRIDGE["token"]
23
+ if len(sys.argv) > 2:
24
+ XML_PATH = sys.argv[2]
25
+ else:
26
+ revit_version = BRIDGE.get("revitVersion", "")
27
+ candidates = glob.glob(rf"C:\Program Files\Autodesk\Revit {revit_version}*\RevitAPI.xml") \
28
+ or glob.glob(r"C:\Program Files\Autodesk\Revit 20*\RevitAPI.xml")
29
+ if not candidates:
30
+ sys.exit("RevitAPI.xml not found; pass its path as the second argument.")
31
+ XML_PATH = candidates[-1]
32
+
33
+ LAT = []
34
+ def search(query, max_results=10):
35
+ body = json.dumps({"query": query, "max_results": max_results}).encode("utf-8")
36
+ url = f"{BASE}/tools/search_api_docs/execute?token={TOKEN}&timeout_ms=60000"
37
+ req = urllib.request.Request(url, data=body, headers={"content-type": "application/json"}, method="POST")
38
+ t0 = time.perf_counter()
39
+ with urllib.request.urlopen(req, timeout=90) as resp:
40
+ payload = json.load(resp)
41
+ LAT.append((time.perf_counter() - t0) * 1000)
42
+ det = (payload.get("details") or {}).get("payload") or {}
43
+ return det.get("matches") or [], det.get("totalMatches", 0)
44
+
45
+ # ---------------------------------------------------------------- ground truth
46
+ PRIM = {"System.String": "string", "System.Boolean": "bool", "System.Double": "double",
47
+ "System.Int32": "int", "System.Int64": "long", "System.Object": "object",
48
+ "System.Byte": "byte", "System.Single": "float", "System.UInt32": "uint"}
49
+
50
+ def split_top(params): # split on commas outside {}
51
+ out, depth, cur = [], 0, ""
52
+ for ch in params:
53
+ if ch == "{": depth += 1
54
+ elif ch == "}": depth -= 1
55
+ if ch == "," and depth == 0: out.append(cur); cur = ""
56
+ else: cur += ch
57
+ if cur: out.append(cur)
58
+ return out
59
+
60
+ def short_type(t):
61
+ t = t.strip()
62
+ byref = t.endswith("@")
63
+ if byref: t = t[:-1]
64
+ array = ""
65
+ while t.endswith("[]"): array += "[]"; t = t[:-2]
66
+ if t in PRIM: base = PRIM[t]
67
+ elif "{" in t:
68
+ outer, inner = t[:t.index("{")], t[t.index("{")+1:t.rindex("}")]
69
+ outer = outer.split(".")[-1].split("`")[0]
70
+ base = f"{outer}<{', '.join(short_type(x) for x in split_top(inner))}>"
71
+ else:
72
+ base = t.split(".")[-1]
73
+ return base + array + ("&" if byref else "")
74
+
75
+ class Member:
76
+ __slots__ = ("kind","full","typename","member","params","summary","pnames","since")
77
+ def __init__(s, kind, full, typename, member, params, summary, pnames, since):
78
+ s.kind, s.full, s.typename, s.member = kind, full, typename, member
79
+ s.params, s.summary, s.pnames, s.since = params, summary, pnames, since
80
+ @property
81
+ def short_sig_prefix(s):
82
+ shorts = [short_type(p) for p in s.params]
83
+ return f"{s.typename}.{s.member}(" + ", ".join(shorts)
84
+
85
+ def text_of(el):
86
+ if el is None: return None
87
+ return re.sub(r"\s+", " ", "".join(el.itertext())).strip() or None
88
+
89
+ print("parsing RevitAPI.xml ...", flush=True)
90
+ root = ET.parse(XML_PATH).getroot()
91
+ methods, props, types = [], [], []
92
+ for m in root.iter("member"):
93
+ name = m.get("name", "")
94
+ summary = text_of(m.find("summary"))
95
+ since = text_of(m.find("since"))
96
+ pnames = [p.get("name") for p in m.findall("param")]
97
+ if name.startswith("M:"):
98
+ body = name[2:]
99
+ params = ""
100
+ if "(" in body:
101
+ body, params = body[:body.index("(")], body[body.index("(")+1:body.rindex(")")]
102
+ parts = body.split(".")
103
+ if len(parts) < 2: continue
104
+ member, typename = parts[-1], parts[-2]
105
+ if "`" in typename or "`" in member: continue
106
+ full = ".".join(parts)
107
+ methods.append(Member("M", full, typename, member, split_top(params) if params else [], summary, pnames, since))
108
+ elif name.startswith("P:"):
109
+ parts = name[2:].split(".")
110
+ if len(parts) < 2 or "`" in parts[-2]: continue
111
+ props.append(Member("P", name[2:], parts[-2], parts[-1], [], summary, pnames, since))
112
+ elif name.startswith("T:"):
113
+ parts = name[2:].split(".")
114
+ if "`" in parts[-1]: continue
115
+ types.append(Member("T", name[2:], parts[-1], "", [], summary, pnames, since))
116
+
117
+ print(f"golden pool: {len(methods)} methods, {len(props)} properties, {len(types)} types", flush=True)
118
+
119
+ by_ns = defaultdict(list)
120
+ for mm in methods: by_ns[".".join(mm.full.split(".")[:-2])].append(mm)
121
+ real_methods = {(m.typename, m.member) for m in methods}
122
+ overload_count = defaultdict(int)
123
+ for m in methods: overload_count[(m.typename, m.member)] += 1
124
+
125
+ def stratified(pool, n):
126
+ groups = defaultdict(list)
127
+ for m in pool: groups[".".join(m.full.split(".")[:4])].append(m)
128
+ out, keys = [], sorted(groups)
129
+ while len(out) < n and keys:
130
+ for k in list(keys):
131
+ if groups[k]: out.append(groups[k].pop(random.randrange(len(groups[k]))))
132
+ else: keys.remove(k)
133
+ if len(out) >= n: break
134
+ return out
135
+
136
+ R = {} # results
137
+
138
+ # ---------------------------------------------------------- A: exact-name recall
139
+ A = {"n":0,"top1":0,"top3":0,"recall":0}
140
+ sampleA = stratified(methods, 90) + stratified(props, 40) + stratified(types, 30)
141
+ for m in sampleA:
142
+ q = f"{m.typename}.{m.member}" if m.kind in "MP" else m.typename
143
+ matches, _ = search(q)
144
+ target = m.full if m.kind in "MP" else m.full
145
+ names = [x.get("name","") for x in matches]
146
+ A["n"] += 1
147
+ A["recall"] += any(n == target for n in names)
148
+ A["top1"] += bool(names and names[0] == target)
149
+ A["top3"] += any(n == target for n in names[:3])
150
+ print("A done", A, flush=True)
151
+ R["A exact-name"] = A
152
+
153
+ # ------------------------------------------------- B: signatures + spacing variants
154
+ B = {"n":0,"top1_sig":0,"variants_agree":0,"variant_sets":0}
155
+ sigpool = [m for m in methods if 2 <= len(m.params) <= 5]
156
+ sampleB = stratified(sigpool, 60)
157
+ for m in sampleB:
158
+ try: prefix = m.short_sig_prefix
159
+ except Exception: continue
160
+ canonical = prefix # "Type.Member(P1, P2..."
161
+ matches, _ = search(canonical)
162
+ B["n"] += 1
163
+ sigs = [x.get("signature","") for x in matches]
164
+ ok = bool(sigs and sigs[0].startswith(f"{m.typename}.{m.member}("))
165
+ B["top1_sig"] += ok
166
+ variants = [canonical.replace(", ", ","),
167
+ canonical.replace(", ", " , "),
168
+ canonical.replace(", ", ", "),
169
+ canonical.replace("(", "( ", 1)]
170
+ counts = {len(matches)}
171
+ for v in variants:
172
+ vm, _ = search(v)
173
+ counts.add(len(vm))
174
+ B["variant_sets"] += 1
175
+ B["variants_agree"] += (len(counts) == 1)
176
+ print("B done", B, flush=True)
177
+ R["B signature+spacing"] = B
178
+
179
+ # --------------------------------------------------------- C: complex syntax shapes
180
+ C = {"cases": []}
181
+ def probe(label, q, expect_nonzero=True):
182
+ try: matches, total = search(q)
183
+ except Exception as e:
184
+ C["cases"].append((label, q, f"ERROR {e}")); return
185
+ ok = (total > 0) if expect_nonzero else (total == 0)
186
+ top = matches[0]["signature"] if matches else "-"
187
+ C["cases"].append((label, q, f"{'OK' if ok else 'MISS'} total={total} top={top[:70]}"))
188
+
189
+ arr = next((m for m in methods if any(p.endswith("[]") for p in m.params)), None)
190
+ if arr: probe("array param", arr.short_sig_prefix)
191
+ nest = next((m for m in methods if any("{" in p and "{" in p[p.index("{")+1:] for p in m.params)), None)
192
+ if nest: probe("nested generic", nest.short_sig_prefix)
193
+ byref = next((m for m in methods if any(p.endswith("@") for p in m.params)), None)
194
+ if byref: probe("ref/out param", byref.short_sig_prefix)
195
+ ctor = next((m for m in methods if m.member == "#ctor" and m.typename == "FilteredElementCollector"), None)
196
+ probe("constructor", "FilteredElementCollector(Document")
197
+ probe("property", "PDFExportOptions.FileName")
198
+ probe("fully qualified type", "Autodesk.Revit.DB.Document")
199
+ probe("case-insensitive", "wall.create(document, curve")
200
+ probe("Double vs double", "UnitUtils.ConvertToInternalUnits(Double, ForgeTypeId")
201
+ probe("trailing garbage brackets", "Wall.Create(Document, Curve]]", True) # observational
202
+ probe("double periods", "Wall..Create", True) # observational
203
+ print("C done", flush=True)
204
+ R["C complex-syntax"] = C
205
+
206
+ # ------------------------------------------------------------ D: namespace ambiguity
207
+ D = {"cases": []}
208
+ def ns_case(q, want_substr):
209
+ matches, total = search(q)
210
+ top = matches[0]["name"] if matches else "-"
211
+ D["cases"].append((q, want_substr, top, "OK" if want_substr in top else "MISS"))
212
+ for q, want in [
213
+ ("Autodesk.Revit.DB.Architecture.Room", ".Architecture.Room"),
214
+ ("Autodesk.Revit.DB.Mechanical.Space", ".Mechanical.Space"),
215
+ ("Architecture.Room", ".Architecture.Room"),
216
+ ("Mechanical.Duct", ".Mechanical.Duct"),
217
+ ("Plumbing.Pipe", ".Plumbing.Pipe"),
218
+ ("Electrical.Wire", ".Electrical.Wire"),
219
+ ("Structure.Rebar", ".Structure.Rebar"),
220
+ ("Room", "Room"), ("Space", "Space"), ("Area", "Area"),
221
+ ("Connector", "Connector"),
222
+ ]: ns_case(q, want)
223
+ print("D done", flush=True)
224
+ R["D namespace"] = D
225
+
226
+ # ------------------------------------------------------- E: honesty / adversarial
227
+ E = {"n":0,"exact_fp":0,"zero":0,"kinds":defaultdict(lambda:[0,0,0])}
228
+ adv = []
229
+ pool = stratified([m for m in methods if 1 <= len(m.params) <= 4 and m.member != "#ctor"], 120)
230
+ for m in pool[:30]: # misspelled member (drop 2nd letter)
231
+ if len(m.member) < 5: continue
232
+ adv.append(("misspelled-member", f"{m.typename}.{m.member[0]+m.member[2:]}", m))
233
+ for m in pool[30:60]: # nonsense parameter
234
+ adv.append(("nonsense-param", f"{m.typename}.{m.member}(Banana", m))
235
+ for m in pool[60:90]: # wrong declaring class
236
+ other = random.choice(types).typename
237
+ if (other, m.member) in real_methods: continue
238
+ adv.append(("wrong-class", f"{other}.{m.member}", (other, m.member)))
239
+ for m in pool[90:120]: # swapped params (only if that order is not a real overload)
240
+ if len(m.params) < 2 or m.params[0] == m.params[1]: continue
241
+ sw = [m.params[1], m.params[0]] + m.params[2:]
242
+ exists = any(o.params[:len(sw)] == sw for o in methods if (o.typename,o.member)==(m.typename,m.member))
243
+ if exists: continue
244
+ shorts = [short_type(p) for p in sw]
245
+ adv.append(("swapped-params", f"{m.typename}.{m.member}(" + ", ".join(shorts), m))
246
+ for kind, q, _meta in adv:
247
+ matches, total = search(q)
248
+ E["n"] += 1
249
+ zero = (total == 0)
250
+ # exact false positive: a returned entry CLAIMS the impossible thing exists
251
+ fp = False
252
+ if kind == "wrong-class":
253
+ t, mem = _meta
254
+ fp = any(x.get("name","").endswith(f".{t}.{mem}") or f".{t}." in x.get("name","") and x.get("name","").endswith(f".{mem}") for x in matches)
255
+ elif kind == "swapped-params":
256
+ fp = any(x.get("signature","").lower() == (q.lower()+")") for x in matches)
257
+ elif kind == "nonsense-param":
258
+ fp = any("banana" in x.get("signature","").lower() for x in matches)
259
+ elif kind == "misspelled-member":
260
+ bad = q.split(".")[-1].lower()
261
+ fp = any(x.get("name","").lower().endswith("."+bad) for x in matches)
262
+ E["exact_fp"] += fp
263
+ E["zero"] += zero
264
+ k = E["kinds"][kind]; k[0] += 1; k[1] += fp; k[2] += zero
265
+ print("E done", {k: list(v) for k, v in E["kinds"].items()}, flush=True)
266
+ E["kinds"] = {k: list(v) for k, v in E["kinds"].items()}
267
+ R["E honesty"] = E
268
+
269
+ # ------------------------------------------------------------- F: doc fidelity
270
+ F = {"n":0,"param_names_ok":0,"summary_ok":0,"since_ok":0,"since_present":0,"overload_docs_ok":0,"overload_checked":0}
271
+ fsample = stratified([m for m in methods if m.pnames and m.summary and m.member != "#ctor"], 50)
272
+ for m in fsample:
273
+ try: q = m.short_sig_prefix
274
+ except Exception: continue
275
+ matches, _ = search(q)
276
+ if not matches: continue
277
+ top = matches[0]
278
+ F["n"] += 1
279
+ got_pnames = [p.get("name") for p in (top.get("parameters") or [])]
280
+ F["param_names_ok"] += (got_pnames == m.pnames)
281
+ gs, ws = (top.get("summary") or ""), m.summary
282
+ F["summary_ok"] += (re.sub(r"\W","",gs)[:80] == re.sub(r"\W","",ws)[:80])
283
+ if m.since:
284
+ F["since_present"] += 1
285
+ F["since_ok"] += (top.get("since") == m.since)
286
+ # overload-doc attribution: right param COUNT on the top signature
287
+ if overload_count[(m.typename, m.member)] > 1:
288
+ F["overload_checked"] += 1
289
+ sig = top.get("signature","")
290
+ inner = sig[sig.index("(")+1:sig.rindex(")")] if "(" in sig else ""
291
+ F["overload_docs_ok"] += (len(split_top(inner)) == len(m.params) if inner else len(m.params)==0)
292
+ print("F done", F, flush=True)
293
+ R["F fidelity"] = F
294
+
295
+ # ------------------------------------------------------------------ G: latency
296
+ LAT.sort()
297
+ def pct(p): return LAT[min(len(LAT)-1, int(len(LAT)*p))]
298
+ R["G latency"] = {"queries": len(LAT), "cold_first_ms": round(LAT[-1] if not LAT else None or 0),
299
+ "p50_ms": round(pct(0.50)), "p95_ms": round(pct(0.95)), "p99_ms": round(pct(0.99)),
300
+ "max_ms": round(LAT[-1])}
301
+
302
+ json.dump(R, open(sys.argv[1] if len(sys.argv)>1 else "benchmark-results.json","w"), indent=1, default=str)
303
+ print("ALL DONE", flush=True)
304
+ print(json.dumps(R, indent=1, default=str)[:3000])
@@ -165,7 +165,11 @@ namespace RevitBridge.Tools
165
165
  int score;
166
166
  if (member.CompositeLower == q) score = 1000;
167
167
  else if (signatureQuery && member.SignatureLower == q) score = 980;
168
+ // Constructors render as "new Type(...)": accept the natural C# spelling
169
+ // 'FilteredElementCollector(Document' without requiring the 'new' prefix.
170
+ else if (signatureQuery && member.SignatureLower == "new " + q) score = 975;
168
171
  else if (signatureQuery && member.SignatureLower.StartsWith(q, StringComparison.Ordinal)) score = 950;
172
+ else if (signatureQuery && member.SignatureLower.StartsWith("new " + q, StringComparison.Ordinal)) score = 945;
169
173
  else if (member.ShortNameLower == q) score = 900;
170
174
  else if (member.CompositeLower.StartsWith(q, StringComparison.Ordinal)) score = 700;
171
175
  else if (member.ShortNameLower.StartsWith(q, StringComparison.Ordinal)) score = 650;