dsh-data-domain-knowledge 0.1.0-alpha.1

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 (231) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +360 -0
  3. package/cordis.patch.yml +410 -0
  4. package/eval/README.md +76 -0
  5. package/eval/evaluate-retrieval.mjs +121 -0
  6. package/eval/queries.example.jsonl +2 -0
  7. package/lib/client.js +111415 -0
  8. package/lib/types/config-schema.d.ts +35 -0
  9. package/lib/types/config-schema.d.ts.map +1 -0
  10. package/lib/types/config-schema.js +34 -0
  11. package/lib/types/config-schema.js.map +1 -0
  12. package/lib/types/convert/core-loader.d.ts +32 -0
  13. package/lib/types/convert/core-loader.d.ts.map +1 -0
  14. package/lib/types/convert/core-loader.js +60 -0
  15. package/lib/types/convert/core-loader.js.map +1 -0
  16. package/lib/types/convert/host.d.ts +88 -0
  17. package/lib/types/convert/host.d.ts.map +1 -0
  18. package/lib/types/convert/host.js +479 -0
  19. package/lib/types/convert/host.js.map +1 -0
  20. package/lib/types/convert/ingest.d.ts +125 -0
  21. package/lib/types/convert/ingest.d.ts.map +1 -0
  22. package/lib/types/convert/ingest.js +228 -0
  23. package/lib/types/convert/ingest.js.map +1 -0
  24. package/lib/types/convert/settings-schema.d.ts +18 -0
  25. package/lib/types/convert/settings-schema.d.ts.map +1 -0
  26. package/lib/types/convert/settings-schema.js +26 -0
  27. package/lib/types/convert/settings-schema.js.map +1 -0
  28. package/lib/types/convert/types.d.ts +46 -0
  29. package/lib/types/convert/types.d.ts.map +1 -0
  30. package/lib/types/convert/types.js +41 -0
  31. package/lib/types/convert/types.js.map +1 -0
  32. package/lib/types/diagnosis/cards.d.ts +71 -0
  33. package/lib/types/diagnosis/cards.d.ts.map +1 -0
  34. package/lib/types/diagnosis/cards.js +482 -0
  35. package/lib/types/diagnosis/cards.js.map +1 -0
  36. package/lib/types/diagnosis/problem-classes.d.ts +51 -0
  37. package/lib/types/diagnosis/problem-classes.d.ts.map +1 -0
  38. package/lib/types/diagnosis/problem-classes.js +66 -0
  39. package/lib/types/diagnosis/problem-classes.js.map +1 -0
  40. package/lib/types/diagnosis/schema.d.ts +65 -0
  41. package/lib/types/diagnosis/schema.d.ts.map +1 -0
  42. package/lib/types/diagnosis/schema.js +208 -0
  43. package/lib/types/diagnosis/schema.js.map +1 -0
  44. package/lib/types/diagnosis/seed-source.d.ts +20 -0
  45. package/lib/types/diagnosis/seed-source.d.ts.map +1 -0
  46. package/lib/types/diagnosis/seed-source.js +96 -0
  47. package/lib/types/diagnosis/seed-source.js.map +1 -0
  48. package/lib/types/diagnosis/seed.d.ts +50 -0
  49. package/lib/types/diagnosis/seed.d.ts.map +1 -0
  50. package/lib/types/diagnosis/seed.js +242 -0
  51. package/lib/types/diagnosis/seed.js.map +1 -0
  52. package/lib/types/diagnosis/store.d.ts +81 -0
  53. package/lib/types/diagnosis/store.d.ts.map +1 -0
  54. package/lib/types/diagnosis/store.js +409 -0
  55. package/lib/types/diagnosis/store.js.map +1 -0
  56. package/lib/types/diagnosis/types.d.ts +124 -0
  57. package/lib/types/diagnosis/types.d.ts.map +1 -0
  58. package/lib/types/diagnosis/types.js +13 -0
  59. package/lib/types/diagnosis/types.js.map +1 -0
  60. package/lib/types/directory-picker.d.ts +94 -0
  61. package/lib/types/directory-picker.d.ts.map +1 -0
  62. package/lib/types/directory-picker.js +110 -0
  63. package/lib/types/directory-picker.js.map +1 -0
  64. package/lib/types/extract-model.d.ts +76 -0
  65. package/lib/types/extract-model.d.ts.map +1 -0
  66. package/lib/types/extract-model.js +193 -0
  67. package/lib/types/extract-model.js.map +1 -0
  68. package/lib/types/extract.d.ts +121 -0
  69. package/lib/types/extract.d.ts.map +1 -0
  70. package/lib/types/extract.js +549 -0
  71. package/lib/types/extract.js.map +1 -0
  72. package/lib/types/fts.d.ts +96 -0
  73. package/lib/types/fts.d.ts.map +1 -0
  74. package/lib/types/fts.js +184 -0
  75. package/lib/types/fts.js.map +1 -0
  76. package/lib/types/index.d.ts +39 -0
  77. package/lib/types/index.d.ts.map +1 -0
  78. package/lib/types/index.js +1112 -0
  79. package/lib/types/index.js.map +1 -0
  80. package/lib/types/intent/engine.d.ts +108 -0
  81. package/lib/types/intent/engine.d.ts.map +1 -0
  82. package/lib/types/intent/engine.js +501 -0
  83. package/lib/types/intent/engine.js.map +1 -0
  84. package/lib/types/intent/host.d.ts +88 -0
  85. package/lib/types/intent/host.d.ts.map +1 -0
  86. package/lib/types/intent/host.js +389 -0
  87. package/lib/types/intent/host.js.map +1 -0
  88. package/lib/types/intent/preset-tools.d.ts +37 -0
  89. package/lib/types/intent/preset-tools.d.ts.map +1 -0
  90. package/lib/types/intent/preset-tools.js +45 -0
  91. package/lib/types/intent/preset-tools.js.map +1 -0
  92. package/lib/types/intent/settings-schema.d.ts +59 -0
  93. package/lib/types/intent/settings-schema.d.ts.map +1 -0
  94. package/lib/types/intent/settings-schema.js +57 -0
  95. package/lib/types/intent/settings-schema.js.map +1 -0
  96. package/lib/types/intent/store.d.ts +75 -0
  97. package/lib/types/intent/store.d.ts.map +1 -0
  98. package/lib/types/intent/store.js +139 -0
  99. package/lib/types/intent/store.js.map +1 -0
  100. package/lib/types/intent/tools.d.ts +63 -0
  101. package/lib/types/intent/tools.d.ts.map +1 -0
  102. package/lib/types/intent/tools.js +108 -0
  103. package/lib/types/intent/tools.js.map +1 -0
  104. package/lib/types/intent/types.d.ts +189 -0
  105. package/lib/types/intent/types.d.ts.map +1 -0
  106. package/lib/types/intent/types.js +62 -0
  107. package/lib/types/intent/types.js.map +1 -0
  108. package/lib/types/knowledge-bases.d.ts +266 -0
  109. package/lib/types/knowledge-bases.d.ts.map +1 -0
  110. package/lib/types/knowledge-bases.js +1107 -0
  111. package/lib/types/knowledge-bases.js.map +1 -0
  112. package/lib/types/layout.d.ts +262 -0
  113. package/lib/types/layout.d.ts.map +1 -0
  114. package/lib/types/layout.js +312 -0
  115. package/lib/types/layout.js.map +1 -0
  116. package/lib/types/migrate.d.ts +110 -0
  117. package/lib/types/migrate.d.ts.map +1 -0
  118. package/lib/types/migrate.js +280 -0
  119. package/lib/types/migrate.js.map +1 -0
  120. package/lib/types/recall-rank.d.ts +158 -0
  121. package/lib/types/recall-rank.d.ts.map +1 -0
  122. package/lib/types/recall-rank.js +261 -0
  123. package/lib/types/recall-rank.js.map +1 -0
  124. package/lib/types/runtime.d.ts +568 -0
  125. package/lib/types/runtime.d.ts.map +1 -0
  126. package/lib/types/runtime.js +3045 -0
  127. package/lib/types/runtime.js.map +1 -0
  128. package/lib/types/settings-schema.d.ts +117 -0
  129. package/lib/types/settings-schema.d.ts.map +1 -0
  130. package/lib/types/settings-schema.js +92 -0
  131. package/lib/types/settings-schema.js.map +1 -0
  132. package/lib/types/settings-volatile.d.ts +102 -0
  133. package/lib/types/settings-volatile.d.ts.map +1 -0
  134. package/lib/types/settings-volatile.js +114 -0
  135. package/lib/types/settings-volatile.js.map +1 -0
  136. package/lib/types/types.d.ts +799 -0
  137. package/lib/types/types.d.ts.map +1 -0
  138. package/lib/types/types.js +387 -0
  139. package/lib/types/types.js.map +1 -0
  140. package/lib/types/wiki-types.d.ts +68 -0
  141. package/lib/types/wiki-types.d.ts.map +1 -0
  142. package/lib/types/wiki-types.js +322 -0
  143. package/lib/types/wiki-types.js.map +1 -0
  144. package/package.json +101 -0
  145. package/preset/tita-data-agent/agent.cordis.yml +376 -0
  146. package/preset/tita-data-agent/preset.yml +3 -0
  147. package/python/requirements.txt +8 -0
  148. package/scripts/doctor.mjs +70 -0
  149. package/scripts/e2e-pipeline.mjs +355 -0
  150. package/scripts/install-agent-preset.mjs +92 -0
  151. package/scripts/setup-embedding.sh +34 -0
  152. package/scripts/sync-agent-preset.mjs +188 -0
  153. package/src/client/AgentPresetCard.tsx +247 -0
  154. package/src/client/DirectoryPicker.tsx +357 -0
  155. package/src/client/InfoHint.tsx +65 -0
  156. package/src/client/IntentLogicTab.tsx +185 -0
  157. package/src/client/IntentTab.tsx +715 -0
  158. package/src/client/KnowledgeBaseTab.tsx +1454 -0
  159. package/src/client/KnowledgeGraphView.tsx +306 -0
  160. package/src/client/KnowledgePanel.tsx +381 -0
  161. package/src/client/KnowledgeStepRail.tsx +60 -0
  162. package/src/client/NodeSettings.tsx +392 -0
  163. package/src/client/PreviewSections.tsx +116 -0
  164. package/src/client/RecallPanel.tsx +399 -0
  165. package/src/client/TypeDefinitionDrawer.tsx +215 -0
  166. package/src/client/WikiFlowCard.tsx +226 -0
  167. package/src/client/WikiTypeEditor.tsx +332 -0
  168. package/src/client/agent-preset.ts +121 -0
  169. package/src/client/arch/AgentArchitectureGraph.tsx +219 -0
  170. package/src/client/arch/NodeLogicPanel.tsx +133 -0
  171. package/src/client/arch/ProblemDetailPanel.tsx +88 -0
  172. package/src/client/arch/model.ts +691 -0
  173. package/src/client/architecture.css +191 -0
  174. package/src/client/architecture.tsx +530 -0
  175. package/src/client/attribution/AttributionGraph.tsx +104 -0
  176. package/src/client/attribution/AttributionTab.tsx +332 -0
  177. package/src/client/attribution/CaseView.tsx +102 -0
  178. package/src/client/attribution/GraphStage.tsx +87 -0
  179. package/src/client/attribution/RunView.tsx +184 -0
  180. package/src/client/attribution/StageChrome.tsx +403 -0
  181. package/src/client/attribution/ViewSwitch.tsx +107 -0
  182. package/src/client/attribution/graph-options.ts +381 -0
  183. package/src/client/attribution/model.ts +462 -0
  184. package/src/client/config-scope.ts +119 -0
  185. package/src/client/convert/ConvertPanel.tsx +594 -0
  186. package/src/client/convert/styles.css +138 -0
  187. package/src/client/convert/styles.ts +49 -0
  188. package/src/client/format.ts +39 -0
  189. package/src/client/graph/layout.ts +181 -0
  190. package/src/client/graph.css +113 -0
  191. package/src/client/index.ts +98 -0
  192. package/src/client/problem-domains.ts +114 -0
  193. package/src/client/styles.css +940 -0
  194. package/src/client/styles.ts +11 -0
  195. package/src/client/useFitScale.ts +68 -0
  196. package/src/config-schema.ts +45 -0
  197. package/src/convert/core-loader.ts +68 -0
  198. package/src/convert/host.ts +579 -0
  199. package/src/convert/ingest.ts +367 -0
  200. package/src/convert/settings-schema.ts +27 -0
  201. package/src/convert/types.ts +65 -0
  202. package/src/diagnosis/cards.ts +498 -0
  203. package/src/diagnosis/problem-classes.ts +117 -0
  204. package/src/diagnosis/schema.ts +260 -0
  205. package/src/diagnosis/seed-source.ts +107 -0
  206. package/src/diagnosis/seed.ts +291 -0
  207. package/src/diagnosis/store.ts +432 -0
  208. package/src/diagnosis/types.ts +152 -0
  209. package/src/directory-picker.ts +164 -0
  210. package/src/extract-model.ts +227 -0
  211. package/src/extract.ts +632 -0
  212. package/src/fts.ts +203 -0
  213. package/src/index.ts +1091 -0
  214. package/src/intent/README.md +63 -0
  215. package/src/intent/engine.ts +527 -0
  216. package/src/intent/host.ts +462 -0
  217. package/src/intent/preset-tools.ts +48 -0
  218. package/src/intent/settings-schema.ts +63 -0
  219. package/src/intent/store.ts +143 -0
  220. package/src/intent/tools.ts +139 -0
  221. package/src/intent/types.ts +245 -0
  222. package/src/knowledge-bases.ts +1160 -0
  223. package/src/layout.ts +350 -0
  224. package/src/migrate.ts +262 -0
  225. package/src/python/embedding_worker.py +471 -0
  226. package/src/recall-rank.ts +299 -0
  227. package/src/runtime.ts +3069 -0
  228. package/src/settings-schema.ts +94 -0
  229. package/src/settings-volatile.ts +122 -0
  230. package/src/types.ts +1140 -0
  231. package/src/wiki-types.ts +357 -0
@@ -0,0 +1,471 @@
1
+ #!/usr/bin/env python3
2
+ """Small JSONL bridge for the local WeMM SentenceTransformers backend.
3
+
4
+ The Host deliberately talks to this process over stdin/stdout instead of
5
+ importing Python from Node. That keeps the DSH plugin installable without a
6
+ Python dependency while still making the real multimodal model a first-class
7
+ local backend on macOS (MPS) and Linux (CPU/CUDA as selected by PyTorch).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import shutil
15
+ import sys
16
+ import tempfile
17
+ from contextlib import redirect_stdout
18
+ from typing import Any
19
+
20
+
21
+ MODEL_CACHE: dict[tuple[str, str], tuple[Any, str]] = {}
22
+ # (model, device, health) — the health dict is filled once, at load, by
23
+ # `reranker_health` and reused for every request in this process.
24
+ RERANK_CACHE: dict[tuple[str, str], tuple[Any, str, dict[str, Any]]] = {}
25
+
26
+ # A real XLM-R vocabulary is ~250k entries. A *degenerate* one still loads and
27
+ # still returns numbers: with `sentencepiece.bpe.model` / `tokenizer.json` missing
28
+ # from the local snapshot, `XLMRobertaTokenizer` came up with `vocab_size = 5`
29
+ # (the five special tokens only) and tokenized every input to `<s> <unk>… </s>`.
30
+ # Measured: three texts (relevant / unrelated-topic / pure noise) scored
31
+ # 0.999, 0.998, 0.9994 — i.e. the "reranker" was ranking `<unk>` soup, and stage 4
32
+ # reported `status: ready` with `dropped: 0` the whole time. Silent, so it has to
33
+ # be a hard error at load rather than something a caller might notice.
34
+ RERANK_MIN_VOCAB = 1000
35
+
36
+
37
+ def emit(value: dict[str, Any]) -> None:
38
+ sys.stdout.write(json.dumps(value, ensure_ascii=False, separators=(",", ":")) + "\n")
39
+ sys.stdout.flush()
40
+
41
+
42
+ def choose_device(requested: str) -> str:
43
+ if requested in {"mps", "cpu"}:
44
+ return requested
45
+ try:
46
+ import torch
47
+
48
+ if getattr(torch.backends, "mps", None) is not None and torch.backends.mps.is_available():
49
+ return "mps"
50
+ except Exception:
51
+ pass
52
+ return "cpu"
53
+
54
+
55
+ def load_local_first(build):
56
+ """Build a HuggingFace-backed model **from the local cache first**.
57
+
58
+ A machine that already has the weights must not need huggingface.co to answer
59
+ a query. `SentenceTransformer(...)` / `CrossEncoder(...)` resolve a bare repo id
60
+ by contacting the Hub, and with no egress that call does not fail fast — it
61
+ *hangs* until the plugin's timeout. Measured on this machine: the reranker
62
+ weights were fully present in `~/.cache/huggingface/hub` (1.0G), yet the first
63
+ rerank after startup spent the whole 60s timeout trying to reach the Hub and
64
+ came back as `EMBEDDING_TIMEOUT` — the wrong subsystem *and* the wrong cause.
65
+ `local_files_only=True` turns a cached model into an instant offline load; the
66
+ networked attempt stays for the genuinely-uncached first run.
67
+
68
+ Note what this flag does **not** prove: the first attempt below succeeds on a
69
+ *partially* downloaded snapshot too. That is exactly how the missing reranker
70
+ tokenizer went unnoticed — `local_files_only=True` loaded in 0.1s and returned
71
+ a tokenizer with an empty vocabulary. "Loads offline" is not "is usable";
72
+ `reranker_health` is what checks the second half.
73
+
74
+ `build(local_only)` receives the flag because the argument has to reach the
75
+ constructor, and only the caller knows its exact signature.
76
+ """
77
+ try:
78
+ return build(True)
79
+ except Exception:
80
+ # Uncached cache, or a cache that cannot satisfy the load: let the normal
81
+ # path run so its own error — which names the real problem — surfaces.
82
+ return build(False)
83
+
84
+ def load_model(model_name: str, requested_device: str):
85
+ import torch
86
+ from sentence_transformers import SentenceTransformer
87
+
88
+ device = choose_device(requested_device)
89
+ # Do not force torch_dtype here. The quantized checkpoint owns its weight
90
+ # loading policy; forcing a dtype breaks some custom WeMM loaders.
91
+ if device == "mps":
92
+ torch.set_float32_matmul_precision("high")
93
+ def load(path: str, target: str, local_only: bool = False):
94
+ kwargs = {"local_files_only": True} if local_only else {}
95
+ return SentenceTransformer(path, trust_remote_code=True, device=target, **kwargs)
96
+
97
+ def move_float8_storage_to_mps(model: Any) -> None:
98
+ # MPS does not expose Float8_e4m3fn tensors. The quantized checkpoint
99
+ # uses FP8 buffers for a subset of weights, while its forward methods
100
+ # already dequantize them to float32/bfloat16. Converting those buffers
101
+ # before the device move preserves the computation and makes the
102
+ # checkpoint usable on Apple Silicon; the W4A8 uint8 buffers stay packed.
103
+ float8 = getattr(torch, "float8_e4m3fn", None)
104
+ if float8 is None:
105
+ raise RuntimeError("MPS_FLOAT8_UNAVAILABLE")
106
+ for module in model.modules():
107
+ for name, buffer in list(module._buffers.items()):
108
+ if buffer is not None and buffer.dtype == float8:
109
+ module._buffers[name] = buffer.to(dtype=torch.bfloat16)
110
+
111
+ # The released W4A8/FP8 checkpoint intentionally omits lm_head.weight, but
112
+ # its config still says tie_word_embeddings=true. Transformers then tries
113
+ # to assign the FP8 input buffer to an nn.Parameter and aborts. Keep the
114
+ # downloaded model immutable and load through a tiny symlinked directory
115
+ # with only this inference-irrelevant config flag corrected.
116
+ patched_dir = None
117
+ load_path = model_name
118
+ if os.path.isdir(model_name):
119
+ config_path = os.path.join(model_name, "config.json")
120
+ try:
121
+ with open(config_path, "r", encoding="utf-8") as handle:
122
+ config = json.load(handle)
123
+ if config.get("tie_word_embeddings") is True:
124
+ patched_dir = tempfile.mkdtemp(prefix="tita-wemm-")
125
+ for name in os.listdir(model_name):
126
+ source = os.path.join(model_name, name)
127
+ target = os.path.join(patched_dir, name)
128
+ if name == "config.json":
129
+ config["tie_word_embeddings"] = False
130
+ with open(target, "w", encoding="utf-8") as handle:
131
+ json.dump(config, handle)
132
+ else:
133
+ os.symlink(source, target)
134
+ load_path = patched_dir
135
+ except Exception:
136
+ if patched_dir is not None:
137
+ shutil.rmtree(patched_dir, ignore_errors=True)
138
+ patched_dir = None
139
+
140
+ def attempt(local_only: bool):
141
+ try:
142
+ if device == "mps":
143
+ model = load(load_path, "cpu", local_only)
144
+ move_float8_storage_to_mps(model)
145
+ model.to("mps")
146
+ return model, device
147
+ return load(load_path, device, local_only), device
148
+ except Exception:
149
+ # MPS support for custom/quantized checkpoints can vary by torch
150
+ # build. `auto` remains useful where CPU is the safe fallback.
151
+ if requested_device == "auto" and device == "mps":
152
+ return load(load_path, "cpu", local_only), "cpu"
153
+ raise
154
+
155
+ try:
156
+ return load_local_first(attempt)
157
+ finally:
158
+ if patched_dir is not None:
159
+ shutil.rmtree(patched_dir, ignore_errors=True)
160
+
161
+
162
+ def make_input(item: Any) -> Any:
163
+ if isinstance(item, str):
164
+ return item
165
+ if not isinstance(item, dict):
166
+ raise ValueError("embedding input must be a string or object")
167
+ text = str(item.get("text") or "")
168
+ image = item.get("image")
169
+ if image:
170
+ # WeMM uses a shared text/image space. Keeping the markdown image and
171
+ # its surrounding text in one input is what makes image-in-markdown
172
+ # searchable by a natural-language query.
173
+ value = {"image": os.fspath(image), "text": text or "Represent this image for retrieval."}
174
+ return value
175
+ return text
176
+
177
+
178
+ def cached_model(model_name: str, requested_device: str):
179
+ key = (model_name, requested_device)
180
+ cached = MODEL_CACHE.get(key)
181
+ if cached is not None:
182
+ return cached
183
+ # Some model-loading libraries log informational messages to stdout. The
184
+ # stdout channel is the JSONL protocol, so keep those logs on stderr.
185
+ with redirect_stdout(sys.stderr):
186
+ loaded = load_model(model_name, requested_device)
187
+ MODEL_CACHE[key] = loaded
188
+ return loaded
189
+
190
+
191
+ def has_official_path(model_wrapper: Any) -> bool:
192
+ """Whether this checkpoint exposes WeMM's own processor + embedding head.
193
+
194
+ WeMM's card is explicit that the official path is the processor + `model.embedding`
195
+ pair, and here it is not a preference. `SentenceTransformer.encode` on this
196
+ checkpoint returns **the same vector for every input** (measured: four unrelated
197
+ texts — Chinese prose, English SQL — pairwise cosine 1.000000, at both 2048 and
198
+ 1024 dimensions, on CPU as well as MPS). The official path on the same texts gives
199
+ 0.208 / 0.361 / 0.461. So the wrapper path is not a fallback for this checkpoint,
200
+ it is a trap; the Host's encoder health gate is the second line of defence.
201
+ """
202
+ # `SentenceTransformer` subclasses `torch.nn.Sequential`, so `isinstance(..., (list, tuple))`
203
+ # is False even though `model_wrapper[0]` is exactly the transformer module. Indexing is
204
+ # the capability test that actually holds.
205
+ try:
206
+ transformer = model_wrapper[0]
207
+ except Exception:
208
+ transformer = None
209
+ processor = getattr(transformer, "processor", None)
210
+ inner_model = getattr(transformer, "model", None)
211
+ return processor is not None and inner_model is not None and hasattr(inner_model, "embedding")
212
+
213
+
214
+ def _render_text(processor: Any, text: str) -> str:
215
+ return processor.apply_chat_template(
216
+ [{"role": "user", "content": [{"type": "text", "text": text}]}],
217
+ tokenize=False,
218
+ add_generation_prompt=False,
219
+ )
220
+
221
+
222
+ def encode_official_text(model_wrapper: Any, texts: list[str], device: str):
223
+ """Encode text-only inputs through the checkpoint's own processor path.
224
+
225
+ Batched on purpose: the processor accepts a list of rendered conversations and
226
+ pads them, and the result matches the per-item path to cos 0.9999 (measured) at a
227
+ fifth of the time. Per-item would be correct but needlessly slow — a 44-note store
228
+ re-encodes on every `rebuild all`.
229
+ """
230
+ import torch
231
+
232
+ transformer = model_wrapper[0]
233
+ processor = transformer.processor
234
+ inner_model = transformer.model
235
+ rendered = [_render_text(processor, text) for text in texts]
236
+ inputs = processor(text=rendered, return_tensors="pt", padding=True)
237
+ inputs = {key: (value.to(device) if isinstance(value, torch.Tensor) else value) for key, value in inputs.items()}
238
+ with torch.inference_mode():
239
+ embedding = inner_model.embedding(**inputs)
240
+ return embedding.detach().to("cpu").float().numpy()
241
+
242
+
243
+ def encode_multimodal(model_wrapper: Any, items: list[dict[str, Any]], device: str):
244
+ """Encode image-bearing inputs through the official WeMM processor path.
245
+
246
+ SentenceTransformers 6.0's generic `apply_chat_template(tokenize=True)` path can
247
+ produce image features without the matching `<|image_pad|>` token for this
248
+ checkpoint, so the explicit processor path is used for Markdown images.
249
+ """
250
+ import torch
251
+ from qwen_vl_utils import process_vision_info
252
+
253
+ transformer = model_wrapper[0]
254
+ processor = transformer.processor
255
+ inner_model = transformer.model
256
+ outputs = []
257
+ for item in items:
258
+ messages = [{"role": "user", "content": [
259
+ {"type": "image", "image": os.fspath(item["image"])},
260
+ {"type": "text", "text": str(item.get("text") or "Represent this image for retrieval.")},
261
+ ]}]
262
+ rendered = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
263
+ images, videos, video_kwargs = process_vision_info(
264
+ messages,
265
+ image_patch_size=getattr(transformer, "image_patch_size", 16),
266
+ return_video_kwargs=True,
267
+ return_video_metadata=True,
268
+ )
269
+ kwargs = {"text": [rendered], "images": images, "videos": videos, "return_tensors": "pt", "padding": True, **video_kwargs}
270
+ inputs = processor(**kwargs)
271
+ inputs = {key: value.to(device) if isinstance(value, torch.Tensor) else value for key, value in inputs.items()}
272
+ with torch.inference_mode():
273
+ embedding = inner_model.embedding(**inputs)
274
+ outputs.append(embedding.detach().to("cpu").float().numpy()[0])
275
+ return outputs
276
+
277
+
278
+ def encode(request: dict[str, Any]) -> dict[str, Any]:
279
+ model_name = str(request.get("model") or "ewin-reg/WeMM-Embedding-2B-Quantized")
280
+ requested_device = str(request.get("device") or "auto")
281
+ dimension = int(request.get("dimension") or 1024)
282
+ batch_size = max(1, int(request.get("batchSize") or 8))
283
+ raw_inputs = request.get("inputs")
284
+ if not isinstance(raw_inputs, list) or not raw_inputs:
285
+ raise ValueError("inputs must be a non-empty array")
286
+ if dimension < 1:
287
+ raise ValueError("dimension must be positive")
288
+
289
+ model, device = cached_model(model_name, requested_device)
290
+ import numpy as np
291
+
292
+ vectors: list[list[float]] = []
293
+ # A single model process handles all batches, which is important for large
294
+ # knowledge stores: loading a 1.8GB checkpoint once is acceptable; loading it once
295
+ # per document is not.
296
+ official = has_official_path(model)
297
+ for start in range(0, len(raw_inputs), batch_size):
298
+ raw_batch = raw_inputs[start : start + batch_size]
299
+ batch = [make_input(item) for item in raw_batch]
300
+ has_images = any(isinstance(item, dict) and item.get("image") for item in raw_batch)
301
+ if official and not has_images:
302
+ # The common case: a whole batch of text through the official path.
303
+ encoded = encode_official_text(model, [str(item) for item in batch], device)
304
+ elif official:
305
+ # A batch may mix ordinary text with an image-bearing unit. The low-level
306
+ # multimodal processor requires an image for every item, so keep the model
307
+ # loaded once but route each item through the path it needs.
308
+ rows = []
309
+ for raw_item, text_item in zip(raw_batch, batch):
310
+ if isinstance(raw_item, dict) and raw_item.get("image"):
311
+ item_encoded = np.asarray(encode_multimodal(model, [raw_item], device), dtype=np.float32)
312
+ else:
313
+ item_encoded = np.asarray(encode_official_text(model, [str(text_item)], device), dtype=np.float32)
314
+ if item_encoded.ndim == 1:
315
+ item_encoded = item_encoded.reshape(1, -1)
316
+ rows.extend(item_encoded.tolist())
317
+ encoded = rows
318
+ else:
319
+ # A generic SentenceTransformers checkpoint: there is no WeMM head to use,
320
+ # so the wrapper's own encode is the only option. `has_official_path`
321
+ # documents why that path cannot be trusted for WeMM itself.
322
+ encoded = model.encode(
323
+ batch,
324
+ batch_size=batch_size,
325
+ normalize_embeddings=True,
326
+ truncate_dim=dimension,
327
+ convert_to_numpy=True,
328
+ show_progress_bar=False,
329
+ )
330
+ array = np.asarray(encoded, dtype=np.float32)
331
+ if array.ndim == 1:
332
+ array = array.reshape(1, -1)
333
+ # The explicit multimodal WeMM path returns the native 2048-d vector; the
334
+ # generic SentenceTransformers path can apply MRL truncation for us. Keep both
335
+ # paths identical by applying the requested dimension here too.
336
+ if array.ndim == 2 and array.shape[1] > dimension:
337
+ array = array[:, :dimension]
338
+ if array.ndim != 2 or array.shape[1] != dimension:
339
+ raise RuntimeError(f"embedding shape mismatch: expected (*, {dimension}), got {tuple(array.shape)}")
340
+ # The model promises normalized vectors, but re-normalize after MRL truncation
341
+ # so cosine / int8 scoring stays mathematically consistent.
342
+ norms = np.linalg.norm(array, axis=1, keepdims=True)
343
+ array = array / np.maximum(norms, 1e-12)
344
+ vectors.extend(array.tolist())
345
+ return {"ok": True, "device": device, "dimension": dimension, "vectors": vectors}
346
+
347
+
348
+ def reranker_health(model) -> dict[str, Any]:
349
+ """Refuse a cross-encoder whose tokenizer has no vocabulary.
350
+
351
+ Runs once per loaded model. Both checks are cheap and catch the same failure
352
+ mode from two directions: the vocabulary size (a *present* tokenizer) and the
353
+ probe sentence (a tokenizer that is present but resolves nothing).
354
+ """
355
+ tokenizer = getattr(model, "tokenizer", None)
356
+ vocab_size = int(getattr(tokenizer, "vocab_size", 0) or 0)
357
+ if vocab_size < RERANK_MIN_VOCAB:
358
+ raise RuntimeError(
359
+ f"RERANK_TOKENIZER_DEGENERATE: vocab_size={vocab_size} (< {RERANK_MIN_VOCAB}); "
360
+ "本地模型快照缺少分词器词表(bge-reranker-base 需要 tokenizer.json 或 "
361
+ "sentencepiece.bpe.model,见 docs/0024-本地模型运行时.md)"
362
+ )
363
+ unknown = getattr(tokenizer, "unk_token_id", None)
364
+ special = set(getattr(tokenizer, "all_special_ids", None) or [])
365
+ probe = tokenizer("订单 指标 排查 口径 DAU", add_special_tokens=False)["input_ids"]
366
+ # 特判 `add_special_tokens` 的两种行为:只排除 <unk> 是不够的,`<s>` / `</s>`
367
+ # 也不是「真实词表条目」,否则空词表能靠这两个 id 蒙过检查。
368
+ if not [token for token in probe if token != unknown and token not in special]:
369
+ raise RuntimeError("RERANK_TOKENIZER_DEGENERATE: 探针句全部落到 <unk>,词表里没有真实条目")
370
+ return {"vocabSize": vocab_size}
371
+
372
+
373
+ def predict_raw(model, pairs):
374
+ """Cross-encoder 分数,**在原始 logit 刻度上**。
375
+
376
+ `CrossEncoder.predict` 对 `num_labels == 1` 的模型默认套 sigmoid,返回值落在
377
+ (0, 1);而 `rerankThreshold` 的默认值 0.2 是 WeKnora 在 **logit** 刻度上定的。
378
+ 两个刻度混用会让阈值形同虚设:实测 sigmoid 输出恒 > 0.2,于是 `dropped` 永远是 0,
379
+ 「重排阈值」这个绝对闸门从来没生效过;Host 再 sigmoid 一次做展示,又把无关文档
380
+ (logit −10.2 → 0.0000454)显示成 0.5。
381
+
382
+ 展示用的那一次 sigmoid 由 Host 做(`recall-rank.ts`),这里只给 logit ——
383
+ 阈值与排序都留在一个可以跨查询比较的刻度上。
384
+ """
385
+ try:
386
+ import torch
387
+ except Exception as exc: # pragma: no cover - torch is required to have loaded the model
388
+ raise RuntimeError(f"RERANK_TORCH_MISSING: {exc}") from exc
389
+ try:
390
+ return model.predict(pairs, show_progress_bar=False, activation_fn=torch.nn.Identity())
391
+ except TypeError as exc:
392
+ raise RuntimeError(
393
+ "RERANK_ACTIVATION_UNSUPPORTED: 需要 sentence-transformers 支持 predict(activation_fn=...) "
394
+ "才能拿到原始 logit(阈值刻度)"
395
+ ) from exc
396
+
397
+
398
+ def load_reranker(model_name: str, requested_device: str):
399
+ """Load a BERT cross-encoder for stage 4 rerank.
400
+
401
+ Separate from the embedding model: the reranker scores (query, note) pairs
402
+ instead of producing vectors, and lives in its own Host-side worker process
403
+ so a missing reranker model cannot disable embeddings.
404
+ """
405
+ from sentence_transformers import CrossEncoder
406
+
407
+ device = choose_device(requested_device)
408
+
409
+ def attempt(local_only: bool):
410
+ kwargs = {"local_files_only": True} if local_only else {}
411
+ try:
412
+ return CrossEncoder(model_name, device=device, trust_remote_code=True, **kwargs), device
413
+ except TypeError:
414
+ # Older sentence-transformers releases do not expose trust_remote_code.
415
+ return CrossEncoder(model_name, device=device, **kwargs), device
416
+
417
+ return load_local_first(attempt)
418
+
419
+
420
+ def cached_reranker(model_name: str, requested_device: str):
421
+ key = (model_name, requested_device)
422
+ cached = RERANK_CACHE.get(key)
423
+ if cached is not None:
424
+ return cached
425
+ with redirect_stdout(sys.stderr):
426
+ model, device = load_reranker(model_name, requested_device)
427
+ health = reranker_health(model)
428
+ RERANK_CACHE[key] = (model, device, health)
429
+ return RERANK_CACHE[key]
430
+
431
+
432
+ def rerank(request: dict[str, Any]) -> dict[str, Any]:
433
+ model_name = str(request.get("model") or "BAAI/bge-reranker-base")
434
+ requested_device = str(request.get("device") or "auto")
435
+ query = str(request.get("query") or "")
436
+ documents = request.get("documents")
437
+ if not query:
438
+ raise ValueError("rerank requires a non-empty query")
439
+ if not isinstance(documents, list) or not documents:
440
+ raise ValueError("rerank requires a non-empty documents array")
441
+ model, device, health = cached_reranker(model_name, requested_device)
442
+ pairs = [(query, str(document)) for document in documents]
443
+ with redirect_stdout(sys.stderr):
444
+ scores = predict_raw(model, pairs)
445
+ return {
446
+ "ok": True,
447
+ "device": device,
448
+ "vocabSize": health.get("vocabSize"),
449
+ "scores": [float(score) for score in scores],
450
+ }
451
+
452
+
453
+ def main() -> None:
454
+ for line in sys.stdin:
455
+ if not line.strip():
456
+ continue
457
+ try:
458
+ request = json.loads(line)
459
+ # Stage 4 rerank and stage 2/3 embedding share one worker script so
460
+ # the plugin keeps a single local Python runtime; the `task` field
461
+ # selects the path and both models are cached per process.
462
+ if str(request.get("task") or "") == "rerank":
463
+ emit(rerank(request))
464
+ else:
465
+ emit(encode(request))
466
+ except Exception as exc: # return machine-readable errors to the Host
467
+ emit({"ok": False, "error": f"{type(exc).__name__}: {exc}"})
468
+
469
+
470
+ if __name__ == "__main__":
471
+ main()