@shirlytaylor73/smart-search 0.2.0-beta.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.
- package/LICENSE +21 -0
- package/README.md +188 -0
- package/README.zh-CN.md +180 -0
- package/npm/bin/smart-search.js +68 -0
- package/npm/scripts/postinstall.js +87 -0
- package/npm/scripts/resolve-prerelease-version.js +108 -0
- package/npm/scripts/set-package-version.js +35 -0
- package/npm/scripts/sync-python-version.js +22 -0
- package/npm/scripts/test-wrapper-repair.js +137 -0
- package/npm/scripts/test.js +76 -0
- package/package.json +42 -0
- package/pyproject.toml +36 -0
- package/skills/smart-search-cli/SKILL.md +41 -0
- package/skills/smart-search-cli/agents/openai.yaml +3 -0
- package/skills/smart-search-cli/references/cli-contract.md +7 -0
- package/skills/smart-search-cli/references/cli-core.md +5 -0
- package/skills/smart-search-cli/references/command-patterns.md +12 -0
- package/skills/smart-search-cli/references/provider-routing.md +3 -0
- package/skills/smart-search-cli/references/regression-release.md +28 -0
- package/skills/smart-search-cli/references/setup-config.md +3 -0
- package/src/smart_search/__init__.py +1 -0
- package/src/smart_search/assets/skills/smart-search-cli/SKILL.md +41 -0
- package/src/smart_search/assets/skills/smart-search-cli/agents/openai.yaml +3 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/cli-contract.md +7 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/cli-core.md +5 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/command-patterns.md +12 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/provider-routing.md +3 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/regression-release.md +28 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/setup-config.md +3 -0
- package/src/smart_search/cli.py +3118 -0
- package/src/smart_search/config.py +809 -0
- package/src/smart_search/embedding_presets.py +40 -0
- package/src/smart_search/intent_router.py +757 -0
- package/src/smart_search/logger.py +43 -0
- package/src/smart_search/providers/__init__.py +20 -0
- package/src/smart_search/providers/base.py +41 -0
- package/src/smart_search/providers/context7.py +141 -0
- package/src/smart_search/providers/exa.py +206 -0
- package/src/smart_search/providers/jina.py +136 -0
- package/src/smart_search/providers/openai_compatible.py +541 -0
- package/src/smart_search/providers/xai_responses.py +117 -0
- package/src/smart_search/providers/zhipu.py +143 -0
- package/src/smart_search/providers/zhipu_mcp.py +230 -0
- package/src/smart_search/service.py +3445 -0
- package/src/smart_search/skill_installer.py +342 -0
- package/src/smart_search/sources.py +429 -0
- package/src/smart_search/utils.py +220 -0
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import math
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .embedding_presets import embedding_preset_for_model, embedding_threshold_commands
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
ALLOWED_INTENT_ROUTER_MODES = {"hybrid", "rules", "off"}
|
|
13
|
+
ROUTABLE_CAPABILITIES = {"docs_search", "web_search", "web_fetch"}
|
|
14
|
+
|
|
15
|
+
DOCS_INTENT_KEYWORDS = {
|
|
16
|
+
"api",
|
|
17
|
+
"sdk",
|
|
18
|
+
"library",
|
|
19
|
+
"framework",
|
|
20
|
+
"docs",
|
|
21
|
+
"documentation",
|
|
22
|
+
"reference",
|
|
23
|
+
"react",
|
|
24
|
+
"next.js",
|
|
25
|
+
"vue",
|
|
26
|
+
"python",
|
|
27
|
+
"prisma",
|
|
28
|
+
"langchain",
|
|
29
|
+
"openai",
|
|
30
|
+
"context7",
|
|
31
|
+
"接口",
|
|
32
|
+
"文档",
|
|
33
|
+
"库",
|
|
34
|
+
"框架",
|
|
35
|
+
"函数",
|
|
36
|
+
"参数",
|
|
37
|
+
"配置",
|
|
38
|
+
"接入",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
CURRENT_INTENT_KEYWORDS = {
|
|
42
|
+
"今天",
|
|
43
|
+
"今日",
|
|
44
|
+
"最新",
|
|
45
|
+
"国内",
|
|
46
|
+
"中国",
|
|
47
|
+
"政策",
|
|
48
|
+
"新闻",
|
|
49
|
+
"实时",
|
|
50
|
+
"刚刚",
|
|
51
|
+
"当前",
|
|
52
|
+
"现在",
|
|
53
|
+
"本周",
|
|
54
|
+
"本月",
|
|
55
|
+
"战报",
|
|
56
|
+
"比分",
|
|
57
|
+
"赛程",
|
|
58
|
+
"赛果",
|
|
59
|
+
"季后赛",
|
|
60
|
+
"比赛",
|
|
61
|
+
"nba",
|
|
62
|
+
"足球",
|
|
63
|
+
"篮球",
|
|
64
|
+
"today",
|
|
65
|
+
"latest",
|
|
66
|
+
"current",
|
|
67
|
+
"realtime",
|
|
68
|
+
"live",
|
|
69
|
+
"recent",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
ZH_CURRENT_INTENT_KEYWORDS = {
|
|
73
|
+
"今天",
|
|
74
|
+
"今日",
|
|
75
|
+
"最新",
|
|
76
|
+
"国内",
|
|
77
|
+
"中国",
|
|
78
|
+
"政策",
|
|
79
|
+
"新闻",
|
|
80
|
+
"实时",
|
|
81
|
+
"刚刚",
|
|
82
|
+
"当前",
|
|
83
|
+
"现在",
|
|
84
|
+
"本周",
|
|
85
|
+
"本月",
|
|
86
|
+
"战报",
|
|
87
|
+
"比分",
|
|
88
|
+
"赛程",
|
|
89
|
+
"赛果",
|
|
90
|
+
"季后赛",
|
|
91
|
+
"比赛",
|
|
92
|
+
"足球",
|
|
93
|
+
"篮球",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
FETCH_INTENT_KEYWORDS = {"http://", "https://"}
|
|
97
|
+
|
|
98
|
+
CAPABILITY_UTTERANCES: dict[str, list[str]] = {
|
|
99
|
+
"docs_search": [
|
|
100
|
+
"React useEffect API docs",
|
|
101
|
+
"how to integrate this SDK",
|
|
102
|
+
"Python function parameters reference",
|
|
103
|
+
"OpenAI API documentation",
|
|
104
|
+
"这个 SDK 怎么接入",
|
|
105
|
+
"查一下框架官方文档和配置参数",
|
|
106
|
+
],
|
|
107
|
+
"web_search": [
|
|
108
|
+
"today China AI news",
|
|
109
|
+
"latest policy announcement",
|
|
110
|
+
"current market update",
|
|
111
|
+
"NBA score today",
|
|
112
|
+
"今天国内 AI 新闻",
|
|
113
|
+
"最近有什么最新变化",
|
|
114
|
+
],
|
|
115
|
+
"web_fetch": [
|
|
116
|
+
"verify the claim in this URL https://example.com",
|
|
117
|
+
"summarize this webpage",
|
|
118
|
+
"fetch this PDF",
|
|
119
|
+
"请核验这个链接里的说法 https://example.com",
|
|
120
|
+
"抓取这个网页正文",
|
|
121
|
+
],
|
|
122
|
+
"none": [
|
|
123
|
+
"帮我把这句话翻译成英文",
|
|
124
|
+
"写一封请假邮件",
|
|
125
|
+
"总结下面这段文字",
|
|
126
|
+
"解释这个错误堆栈的含义",
|
|
127
|
+
"帮我给变量起名",
|
|
128
|
+
"把这段 JSON 格式化",
|
|
129
|
+
"生成一个会议纪要模板",
|
|
130
|
+
"用更礼貌的语气改写这句话",
|
|
131
|
+
"给我一个早餐计划",
|
|
132
|
+
"计算 37 乘以 19",
|
|
133
|
+
"explain what recursion means in simple words",
|
|
134
|
+
"write a short haiku about winter",
|
|
135
|
+
"classify these TODO items by priority",
|
|
136
|
+
"make this paragraph shorter",
|
|
137
|
+
"帮我润色项目介绍",
|
|
138
|
+
"不联网也能回答的常识问题",
|
|
139
|
+
"给代码加一点注释",
|
|
140
|
+
"Python 函数是什么意思,用自己的话解释",
|
|
141
|
+
"React 是什么,用中文解释",
|
|
142
|
+
"帮我检查语法和错别字",
|
|
143
|
+
],
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
DEFAULT_ROUTE_CALIBRATION_MODELS = [
|
|
147
|
+
"Qwen/Qwen3-Embedding-8B",
|
|
148
|
+
"Qwen/Qwen3-Embedding-4B",
|
|
149
|
+
"Qwen/Qwen3-Embedding-0.6B",
|
|
150
|
+
"Pro/BAAI/bge-m3",
|
|
151
|
+
"BAAI/bge-large-zh-v1.5",
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
ROUTE_CALIBRATION_QUERIES: dict[str, list[str]] = {
|
|
155
|
+
"docs_search": [
|
|
156
|
+
"React useEffect official docs",
|
|
157
|
+
"Next.js app router caching docs",
|
|
158
|
+
"Python pathlib Path API reference",
|
|
159
|
+
"TypeScript compiler options documentation",
|
|
160
|
+
"LangChain retriever integration guide",
|
|
161
|
+
"OpenAI Responses API parameters",
|
|
162
|
+
"Prisma migration CLI docs",
|
|
163
|
+
"Vue watchEffect API usage",
|
|
164
|
+
"FastAPI dependency injection docs",
|
|
165
|
+
"Docker compose healthcheck reference",
|
|
166
|
+
"这个 SDK 怎么接入",
|
|
167
|
+
"查一下 React hooks 官方文档",
|
|
168
|
+
"Python requests 超时参数怎么配置",
|
|
169
|
+
"Vite 配置 alias 的文档",
|
|
170
|
+
"Context7 怎么查 LangChain 文档",
|
|
171
|
+
"OpenAI embeddings API 文档",
|
|
172
|
+
"Next.js middleware 配置项",
|
|
173
|
+
"Rust tokio select 文档",
|
|
174
|
+
"Pandas groupby 参数说明",
|
|
175
|
+
"Tailwind CSS container query docs",
|
|
176
|
+
],
|
|
177
|
+
"web_search": [
|
|
178
|
+
"今天国内 AI 新闻",
|
|
179
|
+
"latest Nvidia earnings news",
|
|
180
|
+
"current Bitcoin price movement",
|
|
181
|
+
"今日人民币汇率变化",
|
|
182
|
+
"NBA score today Lakers",
|
|
183
|
+
"本周新能源车政策",
|
|
184
|
+
"recent OpenAI product announcement",
|
|
185
|
+
"latest Windows 11 update issue",
|
|
186
|
+
"现在上海天气预警",
|
|
187
|
+
"today stock market close summary",
|
|
188
|
+
"刚刚苹果发布会消息",
|
|
189
|
+
"最近美国大选民调",
|
|
190
|
+
"current oil price news",
|
|
191
|
+
"今日A股收盘行情",
|
|
192
|
+
"latest CVE exploit in the wild",
|
|
193
|
+
"本月中国芯片政策变化",
|
|
194
|
+
"today SpaceX launch status",
|
|
195
|
+
"latest Python release announcement",
|
|
196
|
+
"近期比特币ETF新闻",
|
|
197
|
+
"NBA今日赛程",
|
|
198
|
+
],
|
|
199
|
+
"web_fetch": [
|
|
200
|
+
"summarize https://example.com",
|
|
201
|
+
"fetch this PDF https://example.com/report.pdf",
|
|
202
|
+
"请读取这个网页 https://example.com/post",
|
|
203
|
+
"核验这个链接里的说法 https://example.com/source",
|
|
204
|
+
"extract the main text from https://example.org/article",
|
|
205
|
+
"read this arxiv PDF https://arxiv.org/pdf/2401.00001.pdf",
|
|
206
|
+
"抓取 https://example.com/docs 的正文",
|
|
207
|
+
"summarize this url: http://example.net/page",
|
|
208
|
+
"请打开链接看看写了什么 https://example.com",
|
|
209
|
+
"compare the claim in https://example.com/claim",
|
|
210
|
+
"pull metadata from https://example.com/product",
|
|
211
|
+
"读取这篇博客 https://blog.example.com/a",
|
|
212
|
+
"fetch known URL content https://news.example.com/item",
|
|
213
|
+
"把这个PDF概括一下 https://example.com/file.pdf",
|
|
214
|
+
"verify source page http://example.org/source",
|
|
215
|
+
"get text from URL https://developer.example.com/changelog",
|
|
216
|
+
"read webpage content at https://example.edu/paper",
|
|
217
|
+
"请提取网页正文 https://docs.example.com/install",
|
|
218
|
+
"summarize linked announcement https://company.example.com/news",
|
|
219
|
+
"fetch this public page https://example.com/about",
|
|
220
|
+
],
|
|
221
|
+
"none": [
|
|
222
|
+
"帮我把这句话翻译成英文",
|
|
223
|
+
"写一封请假邮件",
|
|
224
|
+
"总结下面这段文字",
|
|
225
|
+
"解释这个错误堆栈的含义",
|
|
226
|
+
"帮我给变量起名",
|
|
227
|
+
"把这段 JSON 格式化",
|
|
228
|
+
"生成一个会议纪要模板",
|
|
229
|
+
"用更礼貌的语气改写这句话",
|
|
230
|
+
"给我一个早餐计划",
|
|
231
|
+
"计算 37 乘以 19",
|
|
232
|
+
"explain what recursion means in simple words",
|
|
233
|
+
"write a short haiku about winter",
|
|
234
|
+
"classify these TODO items by priority",
|
|
235
|
+
"make this paragraph shorter",
|
|
236
|
+
"帮我润色项目介绍",
|
|
237
|
+
"不联网也能回答的常识问题",
|
|
238
|
+
"给代码加一点注释",
|
|
239
|
+
"Python 函数是什么意思,用自己的话解释",
|
|
240
|
+
"React 是什么,用中文解释",
|
|
241
|
+
"帮我检查语法和错别字",
|
|
242
|
+
],
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
DEFAULT_SEMANTIC_CONFIDENCE_THRESHOLD = 0.74
|
|
246
|
+
DEFAULT_SEMANTIC_CONFIDENCE_MARGIN = 0.05
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@dataclass
|
|
250
|
+
class IntentRouteResult:
|
|
251
|
+
query: str
|
|
252
|
+
intent_router_mode: str
|
|
253
|
+
required_capabilities: list[str] = field(default_factory=list)
|
|
254
|
+
intent_signals: dict[str, Any] = field(default_factory=dict)
|
|
255
|
+
confidence: float = 0.0
|
|
256
|
+
router_engines_used: list[str] = field(default_factory=list)
|
|
257
|
+
degraded: bool = False
|
|
258
|
+
degraded_reason: str = ""
|
|
259
|
+
reasons: list[str] = field(default_factory=list)
|
|
260
|
+
docs_intent: bool = False
|
|
261
|
+
zh_current_intent: bool = False
|
|
262
|
+
web_current_intent: bool = False
|
|
263
|
+
fetch_intent: bool = False
|
|
264
|
+
supplemental_paths: list[str] = field(default_factory=list)
|
|
265
|
+
|
|
266
|
+
def to_dict(self) -> dict[str, Any]:
|
|
267
|
+
return {
|
|
268
|
+
"docs_intent": self.docs_intent,
|
|
269
|
+
"zh_current_intent": self.zh_current_intent,
|
|
270
|
+
"web_current_intent": self.web_current_intent,
|
|
271
|
+
"fetch_intent": self.fetch_intent,
|
|
272
|
+
"supplemental_paths": list(self.supplemental_paths),
|
|
273
|
+
"intent_router_mode": self.intent_router_mode,
|
|
274
|
+
"required_capabilities": list(self.required_capabilities),
|
|
275
|
+
"intent_signals": dict(self.intent_signals),
|
|
276
|
+
"confidence": self.confidence,
|
|
277
|
+
"router_engines_used": list(self.router_engines_used),
|
|
278
|
+
"degraded": self.degraded,
|
|
279
|
+
"degraded_reason": self.degraded_reason,
|
|
280
|
+
"reasons": list(self.reasons),
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def contains_any(query: str, keywords: set[str]) -> bool:
|
|
285
|
+
q = query.lower()
|
|
286
|
+
return any(keyword.lower() in q for keyword in keywords)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def extract_urls(query: str) -> list[str]:
|
|
290
|
+
urls = []
|
|
291
|
+
for match in re.findall(r"https?://[^\s<>\]\)\"']+", query):
|
|
292
|
+
cleaned = match.rstrip(".,;,。;)")
|
|
293
|
+
if cleaned:
|
|
294
|
+
urls.append(cleaned)
|
|
295
|
+
return urls
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _ordered_capabilities(capabilities: set[str]) -> list[str]:
|
|
299
|
+
order = ["docs_search", "web_search", "web_fetch"]
|
|
300
|
+
return [capability for capability in order if capability in capabilities]
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def build_rules_route(
|
|
304
|
+
query: str,
|
|
305
|
+
*,
|
|
306
|
+
validation_level: str = "",
|
|
307
|
+
plan_intent_signals: dict[str, Any] | None = None,
|
|
308
|
+
mode: str = "rules",
|
|
309
|
+
) -> IntentRouteResult:
|
|
310
|
+
plan_intent_signals = plan_intent_signals or {}
|
|
311
|
+
urls = extract_urls(query)
|
|
312
|
+
docs_intent = bool(plan_intent_signals.get("docs_api_intent")) or contains_any(query, DOCS_INTENT_KEYWORDS)
|
|
313
|
+
zh_current_intent = (
|
|
314
|
+
plan_intent_signals.get("locale_domain_scope") == "china"
|
|
315
|
+
or contains_any(query, ZH_CURRENT_INTENT_KEYWORDS)
|
|
316
|
+
)
|
|
317
|
+
web_current_intent = bool(
|
|
318
|
+
zh_current_intent
|
|
319
|
+
or plan_intent_signals.get("recency_requirement") in {"recent", "current"}
|
|
320
|
+
or contains_any(query, CURRENT_INTENT_KEYWORDS)
|
|
321
|
+
)
|
|
322
|
+
fetch_intent = bool(plan_intent_signals.get("known_url")) or bool(urls) or contains_any(query, FETCH_INTENT_KEYWORDS)
|
|
323
|
+
|
|
324
|
+
capabilities: set[str] = set()
|
|
325
|
+
supplemental_paths: list[str] = []
|
|
326
|
+
reasons: list[str] = []
|
|
327
|
+
signal_scores: dict[str, float] = {}
|
|
328
|
+
|
|
329
|
+
def add_capability(capability: str, reason: str, score: float) -> None:
|
|
330
|
+
capabilities.add(capability)
|
|
331
|
+
if capability not in supplemental_paths:
|
|
332
|
+
supplemental_paths.append(capability)
|
|
333
|
+
reasons.append(reason)
|
|
334
|
+
signal_scores[capability] = max(signal_scores.get(capability, 0.0), score)
|
|
335
|
+
|
|
336
|
+
if docs_intent:
|
|
337
|
+
add_capability("docs_search", "rules matched docs/API/library terms", 0.82)
|
|
338
|
+
if web_current_intent:
|
|
339
|
+
add_capability("web_search", "rules matched current/locale/news terms", 0.84)
|
|
340
|
+
if validation_level == "strict":
|
|
341
|
+
add_capability("web_search", "strict validation requires source reinforcement", 0.72)
|
|
342
|
+
if fetch_intent:
|
|
343
|
+
add_capability("web_fetch", "rules matched a known URL or fetch request", 0.95 if urls else 0.78)
|
|
344
|
+
|
|
345
|
+
confidence = max(signal_scores.values(), default=0.35)
|
|
346
|
+
intent_signals: dict[str, Any] = {
|
|
347
|
+
"docs_api_intent": docs_intent,
|
|
348
|
+
"current_or_locale_intent": web_current_intent,
|
|
349
|
+
"known_url": fetch_intent,
|
|
350
|
+
"strict_validation": validation_level == "strict",
|
|
351
|
+
"rule_scores": signal_scores,
|
|
352
|
+
"urls": urls,
|
|
353
|
+
}
|
|
354
|
+
for key, value in plan_intent_signals.items():
|
|
355
|
+
intent_signals.setdefault(key, value)
|
|
356
|
+
return IntentRouteResult(
|
|
357
|
+
query=query,
|
|
358
|
+
intent_router_mode=mode,
|
|
359
|
+
required_capabilities=_ordered_capabilities(capabilities),
|
|
360
|
+
intent_signals=intent_signals,
|
|
361
|
+
confidence=round(confidence, 3),
|
|
362
|
+
router_engines_used=["rules"],
|
|
363
|
+
reasons=reasons or ["rules found no supplemental capability need"],
|
|
364
|
+
docs_intent=docs_intent,
|
|
365
|
+
zh_current_intent=bool(zh_current_intent),
|
|
366
|
+
web_current_intent=web_current_intent,
|
|
367
|
+
fetch_intent=fetch_intent,
|
|
368
|
+
supplemental_paths=_ordered_capabilities(set(supplemental_paths)),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _cosine_similarity(left: list[float], right: list[float]) -> float:
|
|
373
|
+
if not left or not right or len(left) != len(right):
|
|
374
|
+
return 0.0
|
|
375
|
+
dot = sum(a * b for a, b in zip(left, right))
|
|
376
|
+
left_norm = math.sqrt(sum(a * a for a in left))
|
|
377
|
+
right_norm = math.sqrt(sum(b * b for b in right))
|
|
378
|
+
if not left_norm or not right_norm:
|
|
379
|
+
return 0.0
|
|
380
|
+
return dot / (left_norm * right_norm)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _classifier_can_add_capability(capability: str, rules: IntentRouteResult) -> bool:
|
|
384
|
+
if capability != "web_search":
|
|
385
|
+
return True
|
|
386
|
+
signals = rules.intent_signals
|
|
387
|
+
return bool(
|
|
388
|
+
rules.web_current_intent
|
|
389
|
+
or signals.get("strict_validation")
|
|
390
|
+
or signals.get("cross_validation_need") == "high"
|
|
391
|
+
or signals.get("recency_requirement") in {"recent", "current"}
|
|
392
|
+
or signals.get("claim_risk") in {"medium", "high"}
|
|
393
|
+
or rules.fetch_intent
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _semantic_summary(scores: dict[str, float], threshold: float, margin: float) -> dict[str, Any]:
|
|
398
|
+
candidates = [(capability, float(score)) for capability, score in scores.items() if capability in ROUTABLE_CAPABILITIES]
|
|
399
|
+
ranked = sorted(candidates, key=lambda item: item[1], reverse=True)
|
|
400
|
+
top_capability = ranked[0][0] if ranked else ""
|
|
401
|
+
top_score = ranked[0][1] if ranked else 0.0
|
|
402
|
+
second_score = ranked[1][1] if len(ranked) > 1 else 0.0
|
|
403
|
+
score_margin = top_score - second_score if ranked else 0.0
|
|
404
|
+
return {
|
|
405
|
+
"top_capability": top_capability,
|
|
406
|
+
"top_score": top_score,
|
|
407
|
+
"second_score": second_score,
|
|
408
|
+
"margin": score_margin,
|
|
409
|
+
"threshold": threshold,
|
|
410
|
+
"minimum_margin": margin,
|
|
411
|
+
"passed_threshold": bool(top_capability and top_score >= threshold),
|
|
412
|
+
"passed_margin": bool(top_capability and score_margin >= margin),
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _config_source(cfg: Any, key: str) -> str:
|
|
417
|
+
getter = getattr(cfg, "get_config_source", None)
|
|
418
|
+
if callable(getter):
|
|
419
|
+
return str(getter(key))
|
|
420
|
+
return "default"
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _matches_float_text(value: float, expected: str) -> bool:
|
|
424
|
+
try:
|
|
425
|
+
return abs(float(value) - float(expected)) < 0.0005
|
|
426
|
+
except (TypeError, ValueError):
|
|
427
|
+
return False
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _embedding_preset_recommendation(
|
|
431
|
+
model: str,
|
|
432
|
+
threshold: float,
|
|
433
|
+
margin: float,
|
|
434
|
+
threshold_source: str,
|
|
435
|
+
margin_source: str,
|
|
436
|
+
) -> dict[str, Any]:
|
|
437
|
+
preset = embedding_preset_for_model(model)
|
|
438
|
+
if not preset:
|
|
439
|
+
return {}
|
|
440
|
+
threshold_matches = _matches_float_text(threshold, preset.threshold)
|
|
441
|
+
margin_matches = _matches_float_text(margin, preset.margin)
|
|
442
|
+
missing_or_mismatched = (
|
|
443
|
+
threshold_source == "default"
|
|
444
|
+
or margin_source == "default"
|
|
445
|
+
or not threshold_matches
|
|
446
|
+
or not margin_matches
|
|
447
|
+
)
|
|
448
|
+
message = ""
|
|
449
|
+
if missing_or_mismatched:
|
|
450
|
+
message = (
|
|
451
|
+
f"{preset.model} works best with INTENT_EMBEDDING_THRESHOLD={preset.threshold} "
|
|
452
|
+
f"and INTENT_EMBEDDING_MARGIN={preset.margin} based on the current Smart Search calibration set."
|
|
453
|
+
)
|
|
454
|
+
return {
|
|
455
|
+
"embedding_preset_id": preset.preset_id,
|
|
456
|
+
"embedding_preset_model": preset.model,
|
|
457
|
+
"embedding_preset_api_url": preset.api_url,
|
|
458
|
+
"embedding_preset_threshold": preset.threshold,
|
|
459
|
+
"embedding_preset_margin": preset.margin,
|
|
460
|
+
"embedding_preset_threshold_matches": threshold_matches,
|
|
461
|
+
"embedding_preset_margin_matches": margin_matches,
|
|
462
|
+
"embedding_preset_recommended": missing_or_mismatched,
|
|
463
|
+
"embedding_preset_recommendation": message,
|
|
464
|
+
"embedding_preset_commands": embedding_threshold_commands(preset) if missing_or_mismatched else [],
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
class IntentRouter:
|
|
469
|
+
def __init__(self, cfg: Any):
|
|
470
|
+
self.config = cfg
|
|
471
|
+
|
|
472
|
+
def status(self) -> dict[str, Any]:
|
|
473
|
+
errors: list[str] = []
|
|
474
|
+
try:
|
|
475
|
+
mode = self.config.intent_router_mode
|
|
476
|
+
except ValueError as exc:
|
|
477
|
+
mode = ""
|
|
478
|
+
errors.append(str(exc))
|
|
479
|
+
try:
|
|
480
|
+
timeout_seconds = self.config.intent_router_timeout
|
|
481
|
+
except ValueError as exc:
|
|
482
|
+
timeout_seconds = 8.0
|
|
483
|
+
errors.append(str(exc))
|
|
484
|
+
try:
|
|
485
|
+
embedding_threshold = self.config.intent_embedding_threshold
|
|
486
|
+
except ValueError as exc:
|
|
487
|
+
embedding_threshold = DEFAULT_SEMANTIC_CONFIDENCE_THRESHOLD
|
|
488
|
+
errors.append(str(exc))
|
|
489
|
+
try:
|
|
490
|
+
embedding_margin = self.config.intent_embedding_margin
|
|
491
|
+
except ValueError as exc:
|
|
492
|
+
embedding_margin = DEFAULT_SEMANTIC_CONFIDENCE_MARGIN
|
|
493
|
+
errors.append(str(exc))
|
|
494
|
+
embedding_model = self.config.intent_embedding_model or ""
|
|
495
|
+
threshold_source = _config_source(self.config, "INTENT_EMBEDDING_THRESHOLD")
|
|
496
|
+
margin_source = _config_source(self.config, "INTENT_EMBEDDING_MARGIN")
|
|
497
|
+
preset_recommendation = _embedding_preset_recommendation(
|
|
498
|
+
embedding_model,
|
|
499
|
+
embedding_threshold,
|
|
500
|
+
embedding_margin,
|
|
501
|
+
threshold_source,
|
|
502
|
+
margin_source,
|
|
503
|
+
)
|
|
504
|
+
return {
|
|
505
|
+
"mode": mode,
|
|
506
|
+
"ok": not errors,
|
|
507
|
+
"error": "; ".join(errors),
|
|
508
|
+
"embeddings_configured": self._embeddings_configured(),
|
|
509
|
+
"classifier_configured": self._classifier_configured(),
|
|
510
|
+
"embedding_model": embedding_model,
|
|
511
|
+
"embedding_threshold": embedding_threshold,
|
|
512
|
+
"embedding_margin": embedding_margin,
|
|
513
|
+
"embedding_threshold_source": threshold_source,
|
|
514
|
+
"embedding_margin_source": margin_source,
|
|
515
|
+
"classifier_model": self.config.intent_classifier_model or "",
|
|
516
|
+
"timeout_seconds": timeout_seconds,
|
|
517
|
+
"degrades_to_rules": True,
|
|
518
|
+
**preset_recommendation,
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async def route(
|
|
522
|
+
self,
|
|
523
|
+
query: str,
|
|
524
|
+
*,
|
|
525
|
+
validation_level: str = "",
|
|
526
|
+
mode: str = "",
|
|
527
|
+
allow_remote: bool = True,
|
|
528
|
+
plan_intent_signals: dict[str, Any] | None = None,
|
|
529
|
+
) -> IntentRouteResult:
|
|
530
|
+
selected_mode = (mode or self.config.intent_router_mode).strip().lower()
|
|
531
|
+
if selected_mode not in ALLOWED_INTENT_ROUTER_MODES:
|
|
532
|
+
allowed = ", ".join(sorted(ALLOWED_INTENT_ROUTER_MODES))
|
|
533
|
+
raise ValueError(f"Invalid SMART_SEARCH_INTENT_ROUTER: {selected_mode}. Supported values: {allowed}")
|
|
534
|
+
if selected_mode == "off":
|
|
535
|
+
return IntentRouteResult(
|
|
536
|
+
query=query,
|
|
537
|
+
intent_router_mode="off",
|
|
538
|
+
router_engines_used=["off"],
|
|
539
|
+
reasons=["intent router disabled"],
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
rules = build_rules_route(
|
|
543
|
+
query,
|
|
544
|
+
validation_level=validation_level,
|
|
545
|
+
plan_intent_signals=plan_intent_signals,
|
|
546
|
+
mode="rules" if selected_mode == "rules" or not allow_remote else selected_mode,
|
|
547
|
+
)
|
|
548
|
+
if selected_mode == "rules" or not allow_remote:
|
|
549
|
+
return rules
|
|
550
|
+
|
|
551
|
+
degraded_reasons: list[str] = []
|
|
552
|
+
engines = ["rules"]
|
|
553
|
+
semantic: dict[str, Any] = {}
|
|
554
|
+
classifier: dict[str, Any] = {}
|
|
555
|
+
merged_caps = set(rules.required_capabilities)
|
|
556
|
+
merged_signals = dict(rules.intent_signals)
|
|
557
|
+
merged_reasons = list(rules.reasons)
|
|
558
|
+
confidence = rules.confidence
|
|
559
|
+
|
|
560
|
+
if self._embeddings_configured():
|
|
561
|
+
try:
|
|
562
|
+
semantic = await self._semantic_route(query)
|
|
563
|
+
engines.append("embeddings")
|
|
564
|
+
scores = semantic.get("scores") if isinstance(semantic.get("scores"), dict) else {}
|
|
565
|
+
summary = _semantic_summary(
|
|
566
|
+
{capability: float(score) for capability, score in scores.items()},
|
|
567
|
+
self.config.intent_embedding_threshold,
|
|
568
|
+
self.config.intent_embedding_margin,
|
|
569
|
+
)
|
|
570
|
+
for capability, score in (semantic.get("scores") or {}).items():
|
|
571
|
+
if capability in ROUTABLE_CAPABILITIES:
|
|
572
|
+
merged_signals[f"semantic_{capability}_score"] = round(float(score), 3)
|
|
573
|
+
merged_signals.update(
|
|
574
|
+
{
|
|
575
|
+
"semantic_top_capability": summary["top_capability"],
|
|
576
|
+
"semantic_top_score": round(float(summary["top_score"]), 3),
|
|
577
|
+
"semantic_second_score": round(float(summary["second_score"]), 3),
|
|
578
|
+
"semantic_margin": round(float(summary["margin"]), 3),
|
|
579
|
+
"semantic_threshold": round(float(summary["threshold"]), 3),
|
|
580
|
+
"semantic_minimum_margin": round(float(summary["minimum_margin"]), 3),
|
|
581
|
+
"semantic_passed_threshold": bool(summary["passed_threshold"]),
|
|
582
|
+
"semantic_passed_margin": bool(summary["passed_margin"]),
|
|
583
|
+
}
|
|
584
|
+
)
|
|
585
|
+
if summary["passed_threshold"] and summary["passed_margin"]:
|
|
586
|
+
capability = str(summary["top_capability"])
|
|
587
|
+
merged_caps.add(capability)
|
|
588
|
+
merged_reasons.append(
|
|
589
|
+
f"embeddings matched {capability} examples "
|
|
590
|
+
f"(score {summary['top_score']:.3f}, margin {summary['margin']:.3f})"
|
|
591
|
+
)
|
|
592
|
+
confidence = max(confidence, float(summary["top_score"]))
|
|
593
|
+
elif summary["passed_threshold"] and not summary["passed_margin"]:
|
|
594
|
+
merged_reasons.append(
|
|
595
|
+
"embeddings ambiguous: top semantic score passed threshold "
|
|
596
|
+
f"but margin {summary['margin']:.3f} was below {summary['minimum_margin']:.3f}"
|
|
597
|
+
)
|
|
598
|
+
except Exception as exc:
|
|
599
|
+
degraded_reasons.append(f"embeddings unavailable: {exc}")
|
|
600
|
+
else:
|
|
601
|
+
degraded_reasons.append("embeddings not configured")
|
|
602
|
+
|
|
603
|
+
if self._classifier_configured():
|
|
604
|
+
try:
|
|
605
|
+
classifier = await self._classifier_route(query, rules.to_dict(), semantic)
|
|
606
|
+
engines.append("classifier")
|
|
607
|
+
for capability in classifier.get("required_capabilities") or []:
|
|
608
|
+
if capability in ROUTABLE_CAPABILITIES and _classifier_can_add_capability(capability, rules):
|
|
609
|
+
merged_caps.add(capability)
|
|
610
|
+
elif capability in ROUTABLE_CAPABILITIES:
|
|
611
|
+
merged_reasons.append(f"classifier ignored unsupported capability for current signals: {capability}")
|
|
612
|
+
else:
|
|
613
|
+
merged_reasons.append(f"classifier ignored unknown capability: {capability}")
|
|
614
|
+
if classifier.get("provider") or classifier.get("providers"):
|
|
615
|
+
merged_reasons.append("classifier provider choices were ignored; router only accepts capabilities")
|
|
616
|
+
classifier_signals = classifier.get("intent_signals") if isinstance(classifier.get("intent_signals"), dict) else {}
|
|
617
|
+
for key, value in classifier_signals.items():
|
|
618
|
+
if key not in {"provider", "providers", "provider_id"}:
|
|
619
|
+
merged_signals[key] = value
|
|
620
|
+
classifier_confidence = classifier.get("confidence")
|
|
621
|
+
if isinstance(classifier_confidence, (int, float)):
|
|
622
|
+
confidence = max(confidence, float(classifier_confidence))
|
|
623
|
+
for reason in classifier.get("reasons") or []:
|
|
624
|
+
if isinstance(reason, str) and reason:
|
|
625
|
+
merged_reasons.append(f"classifier: {reason}")
|
|
626
|
+
except Exception as exc:
|
|
627
|
+
degraded_reasons.append(f"classifier unavailable: {exc}")
|
|
628
|
+
else:
|
|
629
|
+
degraded_reasons.append("classifier not configured")
|
|
630
|
+
|
|
631
|
+
required_capabilities = _ordered_capabilities(merged_caps)
|
|
632
|
+
return IntentRouteResult(
|
|
633
|
+
query=query,
|
|
634
|
+
intent_router_mode="hybrid",
|
|
635
|
+
required_capabilities=required_capabilities,
|
|
636
|
+
intent_signals=merged_signals,
|
|
637
|
+
confidence=round(min(confidence, 1.0), 3),
|
|
638
|
+
router_engines_used=engines,
|
|
639
|
+
degraded=bool(degraded_reasons),
|
|
640
|
+
degraded_reason="; ".join(degraded_reasons),
|
|
641
|
+
reasons=merged_reasons,
|
|
642
|
+
docs_intent=rules.docs_intent or "docs_search" in required_capabilities,
|
|
643
|
+
zh_current_intent=rules.zh_current_intent,
|
|
644
|
+
web_current_intent=rules.web_current_intent,
|
|
645
|
+
fetch_intent=rules.fetch_intent or "web_fetch" in required_capabilities,
|
|
646
|
+
supplemental_paths=required_capabilities,
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
def _embeddings_configured(self) -> bool:
|
|
650
|
+
return bool(
|
|
651
|
+
self.config.intent_embedding_api_url
|
|
652
|
+
and self.config.intent_embedding_api_key
|
|
653
|
+
and self.config.intent_embedding_model
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
def _classifier_configured(self) -> bool:
|
|
657
|
+
return bool(
|
|
658
|
+
self.config.intent_classifier_api_url
|
|
659
|
+
and self.config.intent_classifier_api_key
|
|
660
|
+
and self.config.intent_classifier_model
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
async def _semantic_route(self, query: str) -> dict[str, Any]:
|
|
664
|
+
utterances: list[tuple[str, str]] = []
|
|
665
|
+
inputs = [query]
|
|
666
|
+
for capability, examples in CAPABILITY_UTTERANCES.items():
|
|
667
|
+
for example in examples:
|
|
668
|
+
utterances.append((capability, example))
|
|
669
|
+
inputs.append(example)
|
|
670
|
+
embeddings = await self._embed(inputs)
|
|
671
|
+
query_embedding = embeddings[0]
|
|
672
|
+
scores: dict[str, float] = {}
|
|
673
|
+
for index, (capability, _example) in enumerate(utterances, start=1):
|
|
674
|
+
score = _cosine_similarity(query_embedding, embeddings[index])
|
|
675
|
+
scores[capability] = max(scores.get(capability, 0.0), score)
|
|
676
|
+
return {
|
|
677
|
+
"scores": scores,
|
|
678
|
+
**_semantic_summary(
|
|
679
|
+
scores,
|
|
680
|
+
self.config.intent_embedding_threshold,
|
|
681
|
+
self.config.intent_embedding_margin,
|
|
682
|
+
),
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async def _embed(self, inputs: list[str]) -> list[list[float]]:
|
|
686
|
+
headers = {
|
|
687
|
+
"Authorization": f"Bearer {self.config.intent_embedding_api_key}",
|
|
688
|
+
"Content-Type": "application/json",
|
|
689
|
+
}
|
|
690
|
+
payload = {"model": self.config.intent_embedding_model, "input": inputs}
|
|
691
|
+
timeout = httpx.Timeout(self.config.intent_router_timeout)
|
|
692
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
693
|
+
response = await client.post(self.config.intent_embedding_api_url, headers=headers, json=payload)
|
|
694
|
+
response.raise_for_status()
|
|
695
|
+
data = response.json()
|
|
696
|
+
rows = data.get("data") if isinstance(data, dict) else None
|
|
697
|
+
if not isinstance(rows, list) or len(rows) < len(inputs):
|
|
698
|
+
raise ValueError("embedding response missing data rows")
|
|
699
|
+
embeddings: list[list[float]] = []
|
|
700
|
+
for row in rows[: len(inputs)]:
|
|
701
|
+
embedding = row.get("embedding") if isinstance(row, dict) else None
|
|
702
|
+
if not isinstance(embedding, list):
|
|
703
|
+
raise ValueError("embedding response row missing embedding")
|
|
704
|
+
embeddings.append([float(value) for value in embedding])
|
|
705
|
+
return embeddings
|
|
706
|
+
|
|
707
|
+
async def _classifier_route(self, query: str, rules: dict[str, Any], semantic: dict[str, Any]) -> dict[str, Any]:
|
|
708
|
+
prompt = {
|
|
709
|
+
"query": query,
|
|
710
|
+
"rules_result": rules,
|
|
711
|
+
"semantic_result": semantic,
|
|
712
|
+
"allowed_capabilities": sorted(ROUTABLE_CAPABILITIES),
|
|
713
|
+
"instruction": (
|
|
714
|
+
"Return strict JSON with required_capabilities, intent_signals, confidence, and reasons. "
|
|
715
|
+
"Choose only allowed capabilities. Do not choose providers."
|
|
716
|
+
),
|
|
717
|
+
}
|
|
718
|
+
headers = {
|
|
719
|
+
"Authorization": f"Bearer {self.config.intent_classifier_api_key}",
|
|
720
|
+
"Content-Type": "application/json",
|
|
721
|
+
}
|
|
722
|
+
payload = {
|
|
723
|
+
"model": self.config.intent_classifier_model,
|
|
724
|
+
"messages": [
|
|
725
|
+
{
|
|
726
|
+
"role": "system",
|
|
727
|
+
"content": "You classify routing capabilities for Smart Search. Output JSON only.",
|
|
728
|
+
},
|
|
729
|
+
{"role": "user", "content": json.dumps(prompt, ensure_ascii=False)},
|
|
730
|
+
],
|
|
731
|
+
"temperature": 0,
|
|
732
|
+
"response_format": {"type": "json_object"},
|
|
733
|
+
}
|
|
734
|
+
timeout = httpx.Timeout(self.config.intent_router_timeout)
|
|
735
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
736
|
+
response = await client.post(self.config.intent_classifier_api_url, headers=headers, json=payload)
|
|
737
|
+
response.raise_for_status()
|
|
738
|
+
data = response.json()
|
|
739
|
+
content = self._extract_classifier_content(data)
|
|
740
|
+
parsed = json.loads(content) if isinstance(content, str) else content
|
|
741
|
+
if not isinstance(parsed, dict):
|
|
742
|
+
raise ValueError("classifier response is not a JSON object")
|
|
743
|
+
return parsed
|
|
744
|
+
|
|
745
|
+
@staticmethod
|
|
746
|
+
def _extract_classifier_content(data: Any) -> Any:
|
|
747
|
+
if isinstance(data, dict) and "required_capabilities" in data:
|
|
748
|
+
return data
|
|
749
|
+
if isinstance(data, dict):
|
|
750
|
+
choices = data.get("choices")
|
|
751
|
+
if isinstance(choices, list) and choices:
|
|
752
|
+
message = choices[0].get("message") if isinstance(choices[0], dict) else None
|
|
753
|
+
if isinstance(message, dict) and "content" in message:
|
|
754
|
+
return message["content"]
|
|
755
|
+
if "output_text" in data:
|
|
756
|
+
return data["output_text"]
|
|
757
|
+
raise ValueError("classifier response missing JSON content")
|