argo-search 1.0.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.
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env python3
2
+ """smart_router.py — local-search 查询智能路由
3
+
4
+ 根据查询特征将请求路由到最优本地引擎组合,覆盖:
5
+ - 中文通用 / 中文新闻 / 代码 / 学术 / 参考百科 / 事实问答 / 垂直实体
6
+ - 与 unified-search 的 route.py 解耦,local-search 内部使用
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import re
14
+ from typing import Any
15
+
16
+ from engine_registry import EngineRegistry, get_registry
17
+
18
+ logger = logging.getLogger("local_search.smart_router")
19
+ if not logger.handlers:
20
+ logger.setLevel(logging.WARNING)
21
+ logger.addHandler(logging.StreamHandler())
22
+
23
+ # 查询特征正则
24
+ _RE_CHINESE = re.compile(r"[一-鿿]")
25
+ _RE_ACADEMIC = re.compile(
26
+ r"\b(paper|arxiv|preprint|doi|citation|abstract|journal|conference|"
27
+ r"peer[-\s]?review|research|survey|review|thesis|dissertation)\b|"
28
+ r"(论文|学术|arxiv|预印本|引用|摘要|期刊|会议|综述|研究)", re.I,
29
+ )
30
+ _RE_CODE = re.compile(
31
+ r"\b(github|stackoverflow|gitlab|npm|pypi|cargo|maven|gradle|package|"
32
+ r"repo|repository|source\s*code|function|class|api|sdk|library|framework|"
33
+ r"python|javascript|typescript|java|go|golang|rust|c\+\+|sql|regex|docker|"
34
+ r"kubernetes|linux|git|error|exception|bug|debug)\b|"
35
+ r"(代码|源码|开源|仓库|函数|类|库|框架|报错|调试|编程|算法|实现)", re.I,
36
+ )
37
+ _RE_NEWS = re.compile(
38
+ r"\b(news|breaking|latest|today|yesterday|headline|event|development|"
39
+ r"update|report)\b|"
40
+ r"(新闻|最新|热点|时事|突发|报道|消息|进展|动态)", re.I,
41
+ )
42
+ _RE_REFERENCE = re.compile(
43
+ r"\b(wiki|wikipedia|wiktionary|encyclopedia|definition|meaning|"
44
+ r"biography|history|geography|film|movie|book|novel|author|imdb|goodreads)\b|"
45
+ r"(百科|词典|定义|含义|是什么|是谁|什么时候|在哪里|历史人物|电影|书籍)", re.I,
46
+ )
47
+ _RE_FACT = re.compile(
48
+ r"\b(what\s+is|who\s+is|when\s+did|where\s+is|how\s+(many|much|old|long)|"
49
+ r"define|meaning\s+of)\b|"
50
+ r"(是什么|是谁|什么时候|在哪里|多少|多少钱|几岁|多大|定义)", re.I,
51
+ )
52
+ _RE_LOCATION = re.compile(
53
+ r"\b(map|maps|location|address|nearby|distance|route|nominatim|"
54
+ r"openstreetmap|osm)\b|"
55
+ r"(地图|地址|附近|路线|导航|位置)", re.I,
56
+ )
57
+
58
+ # 分类 → 默认引擎优先级映射
59
+ CATEGORY_PRIORITY = {
60
+ "academic": ["local_arxiv", "local_semantic_scholar", "local_crossref", "local_pubmed"],
61
+ "code": ["local_github", "local_stackoverflow", "local_gitlab", "local_npm"],
62
+ "news": ["local_bing_news", "local_google_news", "local_duckduckgo_news"],
63
+ "chinese": ["local_baidu", "local_sogou", "local_bing", "local_duckduckgo"],
64
+ "reference": ["local_wikipedia", "local_wiktionary", "local_wikiquote"],
65
+ "vertical": ["local_openstreetmap", "local_imdb", "local_goodreads"],
66
+ "web_general": ["local_bing", "local_duckduckgo", "local_mojeek", "local_startpage"],
67
+ }
68
+
69
+
70
+ def extract_features(query: str) -> dict[str, Any]:
71
+ """提取查询特征向量。"""
72
+ total = max(len(query), 1)
73
+ chinese = len(_RE_CHINESE.findall(query))
74
+ return {
75
+ "chinese_ratio": chinese / total,
76
+ "is_chinese": chinese / total > 0.3,
77
+ "is_academic": bool(_RE_ACADEMIC.search(query)),
78
+ "is_code": bool(_RE_CODE.search(query)),
79
+ "is_news": bool(_RE_NEWS.search(query)),
80
+ "is_reference": bool(_RE_REFERENCE.search(query)),
81
+ "is_fact": bool(_RE_FACT.search(query)),
82
+ "is_location": bool(_RE_LOCATION.search(query)),
83
+ }
84
+
85
+
86
+ def route_query(
87
+ query: str,
88
+ registry: EngineRegistry | None = None,
89
+ preferred_engines: list[str] | None = None,
90
+ max_engines: int = 3,
91
+ require_available: bool = True,
92
+ ) -> dict[str, Any]:
93
+ """根据查询特征选择最优本地引擎组合。
94
+
95
+ Returns:
96
+ {
97
+ "engines": [...],
98
+ "reason": "...",
99
+ "features": {...},
100
+ "domain": "...",
101
+ }
102
+ """
103
+ reg = registry or get_registry()
104
+ features = extract_features(query)
105
+
106
+ # 用户指定引擎优先
107
+ if preferred_engines:
108
+ engines = [e for e in preferred_engines if reg.get_engine(e)]
109
+ if engines:
110
+ return {
111
+ "engines": engines[:max_engines],
112
+ "reason": f"用户指定: {', '.join(engines[:max_engines])}",
113
+ "features": features,
114
+ "domain": "custom",
115
+ }
116
+
117
+ # 特征 → 候选分类
118
+ candidates: list[tuple[str, float]] = []
119
+ if features["is_academic"]:
120
+ candidates.append(("academic", 1.0))
121
+ if features["is_code"]:
122
+ candidates.append(("code", 1.0))
123
+ if features["is_news"]:
124
+ candidates.append(("news", 0.95))
125
+ if features["is_location"]:
126
+ candidates.append(("vertical", 0.9))
127
+ if features["is_reference"]:
128
+ candidates.append(("reference", 0.9))
129
+ if features["is_fact"]:
130
+ # 事实查询优先百科与通用搜索
131
+ candidates.append(("reference", 0.85))
132
+ candidates.append(("web_general", 0.7))
133
+ if features["is_chinese"]:
134
+ candidates.append(("chinese", 0.85))
135
+ # 中文新闻类查询增强
136
+ if features["is_news"]:
137
+ candidates.append(("news", 0.9))
138
+
139
+ # 兜底
140
+ if not candidates:
141
+ candidates.append(("web_general", 0.5))
142
+
143
+ # 去重并排序
144
+ seen: set[str] = set()
145
+ ordered: list[tuple[str, float]] = []
146
+ for cat, score in sorted(candidates, key=lambda x: -x[1]):
147
+ if cat not in seen:
148
+ seen.add(cat)
149
+ ordered.append((cat, score))
150
+
151
+ # 按分类取引擎,再合并去重
152
+ selected: list[str] = []
153
+ reasons: list[str] = []
154
+ for cat, score in ordered:
155
+ for eng in CATEGORY_PRIORITY.get(cat, []):
156
+ if len(selected) >= max_engines:
157
+ break
158
+ spec = reg.get_engine(eng)
159
+ if not spec:
160
+ continue
161
+ if require_available and not spec.get("available", True):
162
+ continue
163
+ if not spec.get("enabled", True):
164
+ continue
165
+ if eng not in selected:
166
+ selected.append(eng)
167
+ reasons.append(f"{eng}({cat})")
168
+ if len(selected) >= max_engines:
169
+ break
170
+
171
+ # 兜底:如果都没选到,返回前几个启用的可用引擎
172
+ if not selected:
173
+ selected = reg.list_engines(available_only=require_available, enabled_only=True)[:max_engines]
174
+ reasons = [f"{e}(fallback)" for e in selected]
175
+
176
+ domain = ordered[0][0] if ordered else "web_general"
177
+
178
+ return {
179
+ "engines": selected,
180
+ "reason": f"特征路由 → {' + '.join(reasons)}",
181
+ "features": features,
182
+ "domain": domain,
183
+ }
184
+
185
+
186
+ def pick_engines(
187
+ query: str,
188
+ registry: EngineRegistry | None = None,
189
+ preferred: list[str] | None = None,
190
+ max_engines: int = 3,
191
+ require_available: bool = True,
192
+ ) -> list[str]:
193
+ """便捷函数:直接返回引擎名列表。"""
194
+ decision = route_query(query, registry, preferred, max_engines, require_available)
195
+ return decision["engines"]
196
+
197
+
198
+ if __name__ == "__main__":
199
+ import argparse
200
+
201
+ parser = argparse.ArgumentParser(description="local-search 智能路由调试")
202
+ parser.add_argument("query", help="搜索查询")
203
+ parser.add_argument("--engine", default="", help="指定引擎,逗号分隔")
204
+ parser.add_argument("--max-engines", type=int, default=3)
205
+ parser.add_argument("--ignore-availability", action="store_true")
206
+ args = parser.parse_args()
207
+
208
+ preferred = [e.strip() for e in args.engine.split(",") if e.strip()] if args.engine else None
209
+ decision = route_query(
210
+ args.query,
211
+ preferred_engines=preferred,
212
+ max_engines=args.max_engines,
213
+ require_available=not args.ignore_availability,
214
+ )
215
+ print(json.dumps(decision, ensure_ascii=False, indent=2))