@dsh-plus/web-search-services 0.1.0 → 0.1.2

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,570 @@
1
+ #!/usr/bin/env python3
2
+ """OpenAI-compatible Chat Completions helper.
3
+
4
+ 仅通过 OpenAI 兼容的 HTTP Chat Completions 端点执行生成式搜索回答。
5
+ 配置只读取 SEARCH_OPENAI_*,避免误用其他工具的 OPENAI_* 环境变量。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import os
13
+ import re
14
+ import sys
15
+ import time
16
+ import urllib.error
17
+ import urllib.request
18
+ from collections.abc import Iterable
19
+ from datetime import datetime, timezone
20
+ from typing import Any
21
+
22
+ from env_loader import load_env_file
23
+
24
+ load_env_file()
25
+
26
+
27
+ class ServiceError(RuntimeError):
28
+ """服务调用失败。"""
29
+
30
+ def __init__(self, message: str, *, status: int | None = None, details: Any = None):
31
+ super().__init__(message)
32
+ self.status = status
33
+ self.details = details
34
+
35
+
36
+ class MissingAPIKey(ServiceError):
37
+ """缺少 API Key。"""
38
+
39
+
40
+ class MissingModel(ServiceError):
41
+ """缺少模型配置。"""
42
+
43
+
44
+ DEFAULT_BASE_URL = "https://api.openai.com/v1"
45
+ DEFAULT_TIMEOUT = 120.0
46
+ DEFAULT_MAX_ELAPSED = 0.0
47
+ DEFAULT_ANSWER_FORMAT = "markdown"
48
+ SEARCH_PROMPT_TEMPLATE = """你好!想请你帮我查询一些信息。下面是当前时间和协作建议,希望能帮助我们更好地完成搜索:
49
+
50
+ {time_context}
51
+
52
+ 1. 关于搜索工具的使用:
53
+ - 如果你目前拥有联网搜索工具,请务必启动搜索功能,先广泛搜索(至少 5 个不同角度或来源线索),再深入整理最相关的 2-3 个方向。
54
+ - 如果你目前无法使用搜索工具,或无法确认你能取得实时信息,请不要勉强回答,也严禁凭借记忆进行猜测、幻想或伪造答案。请直接坦诚地回复:“很抱歉,我目前无法使用搜索工具,无法为您提供最新的实时查询服务。”
55
+
56
+ 2. 关于信息的真实性:
57
+ - 请推断用户真实意图;即使问题很简略,也要考虑相关口径、年份、地区、机构和术语差异。
58
+ - 对搜索到的细节(如数据、时间、事件经过、排名、版本等)进行多方验证,不要仅凭单一来源或“直觉/经验”下结论。
59
+ - 优先引用权威来源:官方文档/官网、政府或机构公告、学术论文、可信媒体、维基或行业数据库;中文话题可中英文交叉检索。
60
+ - 每个关键事实、核心数据或结论都应尽量附真实可信的参考来源链接(URL);无法核验时请明确标注不确定性。
61
+
62
+ 3. 关于回复的格式:
63
+ - 请使用 {answer_format} 格式,并用清晰的结构化方式呈现回答,例如结论先行、小标题、分点步骤或表格。
64
+ - 技术内容请用通俗语言解释术语;如有公式可用 LaTeX,如有脚本可用代码块。
65
+ {platform_section}
66
+
67
+ 我需要你帮我搜索的具体内容是:
68
+ {query}
69
+ """
70
+
71
+ _THINK_BLOCK_RE = re.compile(
72
+ r"<\s*(?:think|thinking|reasoning)\b[^>]*>.*?<\s*/\s*(?:think|thinking|reasoning)\s*>",
73
+ re.IGNORECASE | re.DOTALL,
74
+ )
75
+ _FENCED_THINK_RE = re.compile(
76
+ r"```\s*(?:think|thinking|reasoning)\s*\n.*?```", re.IGNORECASE | re.DOTALL
77
+ )
78
+
79
+
80
+ def _env(name: str) -> str:
81
+ return os.getenv(name, "").strip()
82
+
83
+
84
+ def _float_env(name: str, default: float) -> float:
85
+ value = _env(name)
86
+ if not value:
87
+ return default
88
+ return float(value)
89
+
90
+
91
+ def _bool_env(name: str, default: bool = False) -> bool:
92
+ value = _env(name).lower()
93
+ if not value:
94
+ return default
95
+ return value in {"1", "true", "yes", "on"}
96
+
97
+
98
+ def get_local_time_info() -> str:
99
+ """返回给搜索提示词使用的本地时间上下文。"""
100
+
101
+ try:
102
+ local_tz = datetime.now().astimezone().tzinfo
103
+ local_now = datetime.now(local_tz)
104
+ except Exception: # noqa: BLE001 - 时间上下文失败时安全回退 UTC
105
+ local_now = datetime.now(timezone.utc)
106
+
107
+ weekdays_cn = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
108
+ return (
109
+ "[当前时间上下文]\n"
110
+ f"- Date: {local_now.strftime('%Y-%m-%d')} ({weekdays_cn[local_now.weekday()]})\n"
111
+ f"- Time: {local_now.strftime('%H:%M:%S')}\n"
112
+ f"- Timezone: {local_now.tzname() or 'Local'}"
113
+ )
114
+
115
+
116
+ def _platform_section(platform: str | None = None) -> str:
117
+ value = (platform or "").strip()
118
+ if not value:
119
+ return ""
120
+ return (
121
+ "\n4. 关于搜索平台/来源侧重:\n"
122
+ f" - 请优先关注这些平台、站点或来源类型:{value}\n"
123
+ " - 即使指定了平台,也请在必要时使用其他权威来源交叉验证,不要只凭单一来源下结论。"
124
+ )
125
+
126
+
127
+ def _base_url(value: str | None = None) -> str:
128
+ return (value or _env("SEARCH_OPENAI_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
129
+
130
+
131
+ def _chat_completions_url(base_url: str) -> str:
132
+ base = base_url.rstrip("/")
133
+ if base.endswith("/chat/completions"):
134
+ return base
135
+ if base.endswith("/v1"):
136
+ return f"{base}/chat/completions"
137
+ return f"{base}/v1/chat/completions"
138
+
139
+
140
+ def _api_key() -> str:
141
+ key = _env("SEARCH_OPENAI_API_KEY")
142
+ if not key:
143
+ raise MissingAPIKey("缺少 SEARCH_OPENAI_API_KEY")
144
+ return key
145
+
146
+
147
+ def _model(value: str | None = None) -> str:
148
+ model = (value or _env("SEARCH_OPENAI_MODEL")).strip()
149
+ if not model:
150
+ raise MissingModel(
151
+ "缺少 SEARCH_OPENAI_MODEL 或 --model/--openai-model;OpenAI Chat provider 不设置默认模型"
152
+ )
153
+ return model
154
+
155
+
156
+ def _timeout(value: float | None = None) -> float:
157
+ return float(
158
+ value
159
+ if value is not None
160
+ else _float_env("SEARCH_OPENAI_TIMEOUT", DEFAULT_TIMEOUT)
161
+ )
162
+
163
+
164
+ def _max_elapsed(value: float | None = None) -> float:
165
+ return float(
166
+ value
167
+ if value is not None
168
+ else _float_env("SEARCH_OPENAI_MAX_ELAPSED", DEFAULT_MAX_ELAPSED)
169
+ )
170
+
171
+
172
+ def _answer_format(value: str | None = None) -> str:
173
+ return (
174
+ (value or _env("SEARCH_OPENAI_ANSWER_FORMAT") or DEFAULT_ANSWER_FORMAT)
175
+ .strip()
176
+ .lower()
177
+ )
178
+
179
+
180
+ def _decode_response(raw: bytes) -> Any:
181
+ text = raw.decode("utf-8", "replace")
182
+ try:
183
+ return json.loads(text)
184
+ except json.JSONDecodeError:
185
+ return text
186
+
187
+
188
+ def _redact_value(value: Any, secret: str) -> Any:
189
+ """递归脱敏,避免错误详情中意外回显 key。"""
190
+
191
+ if isinstance(value, str):
192
+ return (
193
+ value.replace(secret, "<redacted-search-openai-key>") if secret else value
194
+ )
195
+ if isinstance(value, list):
196
+ return [_redact_value(item, secret) for item in value]
197
+ if isinstance(value, dict):
198
+ redacted: dict[str, Any] = {}
199
+ for key, item in value.items():
200
+ if str(key).lower() in {"authorization", "api_key", "api-key", "key"}:
201
+ redacted[key] = "<redacted-search-openai-key>"
202
+ else:
203
+ redacted[key] = _redact_value(item, secret)
204
+ return redacted
205
+ return value
206
+
207
+
208
+ def _clean_answer(text: str) -> str:
209
+ """剔除常见显式思考块,并整理空白。"""
210
+
211
+ cleaned = _THINK_BLOCK_RE.sub("", text)
212
+ cleaned = _FENCED_THINK_RE.sub("", cleaned)
213
+ cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
214
+ return cleaned.strip()
215
+
216
+
217
+ def build_user_prompt(
218
+ query: str,
219
+ *,
220
+ answer_format: str = DEFAULT_ANSWER_FORMAT,
221
+ platform: str | None = None,
222
+ prompt_template: str | None = None,
223
+ ) -> str:
224
+ """构造搜索提示词。注意:该提示词用于 user message,不伪装为 system。"""
225
+
226
+ template = (
227
+ prompt_template or _env("SEARCH_OPENAI_SEARCH_PROMPT") or SEARCH_PROMPT_TEMPLATE
228
+ )
229
+ return template.format(
230
+ query=query,
231
+ answer_format=answer_format,
232
+ platform=(platform or "").strip(),
233
+ platform_section=_platform_section(platform),
234
+ time_context=get_local_time_info(),
235
+ )
236
+
237
+
238
+ def _json_or_none(value: str | None) -> dict[str, Any] | None:
239
+ if not value:
240
+ return None
241
+ return json.loads(value)
242
+
243
+
244
+ def _append_delta_content(delta: dict[str, Any], parts: list[str]) -> None:
245
+ """只收集正式回答内容,显式忽略 reasoning/thinking 字段。"""
246
+
247
+ content = delta.get("content")
248
+ if isinstance(content, str):
249
+ parts.append(content)
250
+ elif isinstance(content, list):
251
+ for item in content:
252
+ if isinstance(item, dict):
253
+ text = item.get("text")
254
+ if isinstance(text, str):
255
+ parts.append(text)
256
+ elif isinstance(item, str):
257
+ parts.append(item)
258
+
259
+
260
+ def collect_stream(
261
+ lines: Iterable[bytes], *, started: float | None = None, max_elapsed: float = 0.0
262
+ ) -> dict[str, Any]:
263
+ """收集 SSE 流式响应,返回完整内容与元信息。
264
+
265
+ `max_elapsed <= 0` 表示不设置硬上限;socket timeout 由 urllib 作为空闲超时控制,
266
+ 因此只要服务端持续输出 chunk,就会自然延长等待。
267
+ """
268
+
269
+ started_at = time.monotonic() if started is None else started
270
+ content_parts: list[str] = []
271
+ usage: Any = None
272
+ model: str | None = None
273
+ finish_reason: str | None = None
274
+ chunk_count = 0
275
+ ignored_reasoning = False
276
+ parse_errors: list[str] = []
277
+
278
+ for raw_line in lines:
279
+ if max_elapsed > 0 and time.monotonic() - started_at > max_elapsed:
280
+ raise ServiceError(f"OpenAI Chat 流式响应超过硬上限 {max_elapsed:g} 秒")
281
+ line = raw_line.decode("utf-8", "replace").strip()
282
+ if not line or line.startswith(":"):
283
+ continue
284
+ if not line.startswith("data:"):
285
+ continue
286
+ payload = line[5:].strip()
287
+ if payload == "[DONE]":
288
+ break
289
+ try:
290
+ event = json.loads(payload)
291
+ except json.JSONDecodeError as exc:
292
+ parse_errors.append(str(exc))
293
+ continue
294
+ chunk_count += 1
295
+ if usage is None and event.get("usage") is not None:
296
+ usage = event.get("usage")
297
+ if model is None and isinstance(event.get("model"), str):
298
+ model = event.get("model")
299
+ for choice in event.get("choices") or []:
300
+ if not isinstance(choice, dict):
301
+ continue
302
+ if choice.get("finish_reason") is not None:
303
+ finish_reason = choice.get("finish_reason")
304
+ delta = choice.get("delta") or {}
305
+ if isinstance(delta, dict):
306
+ if any(
307
+ key in delta
308
+ for key in (
309
+ "reasoning_content",
310
+ "reasoning",
311
+ "thinking",
312
+ "reasoning_details",
313
+ )
314
+ ):
315
+ ignored_reasoning = True
316
+ _append_delta_content(delta, content_parts)
317
+ message = choice.get("message") or {}
318
+ if isinstance(message, dict):
319
+ if any(
320
+ key in message
321
+ for key in (
322
+ "reasoning_content",
323
+ "reasoning",
324
+ "thinking",
325
+ "reasoning_details",
326
+ )
327
+ ):
328
+ ignored_reasoning = True
329
+ _append_delta_content(message, content_parts)
330
+
331
+ return {
332
+ "answer": _clean_answer("".join(content_parts)),
333
+ "model": model,
334
+ "finishReason": finish_reason,
335
+ "usage": usage,
336
+ "chunkCount": chunk_count,
337
+ "ignoredReasoning": ignored_reasoning,
338
+ "parseErrors": parse_errors,
339
+ }
340
+
341
+
342
+ def _build_body(
343
+ query: str,
344
+ *,
345
+ model: str,
346
+ answer_format: str,
347
+ platform: str | None = None,
348
+ system_prompt: str | None = None,
349
+ thinking_effort: str | None = None,
350
+ thinking_field: str | None = None,
351
+ max_tokens: int | None = None,
352
+ temperature: float | None = None,
353
+ extra: dict[str, Any] | None = None,
354
+ ) -> dict[str, Any]:
355
+ messages: list[dict[str, str]] = []
356
+ if system_prompt:
357
+ # 只有用户显式提供 system prompt 时才发送;默认搜索提示词始终放在 user message。
358
+ messages.append({"role": "system", "content": system_prompt})
359
+ messages.append(
360
+ {
361
+ "role": "user",
362
+ "content": build_user_prompt(query, answer_format=answer_format, platform=platform),
363
+ }
364
+ )
365
+
366
+ body: dict[str, Any] = {"model": model, "messages": messages, "stream": True}
367
+ effort = thinking_effort or _env("SEARCH_OPENAI_THINKING_EFFORT")
368
+ field = (
369
+ thinking_field
370
+ if thinking_field is not None
371
+ else (_env("SEARCH_OPENAI_THINKING_FIELD") or "reasoning_effort")
372
+ )
373
+ if effort and field:
374
+ body[field] = effort
375
+ if max_tokens is not None:
376
+ body["max_tokens"] = max_tokens
377
+ if temperature is not None:
378
+ body["temperature"] = temperature
379
+ if _bool_env("SEARCH_OPENAI_STREAM_INCLUDE_USAGE", False):
380
+ body["stream_options"] = {"include_usage": True}
381
+ if extra:
382
+ body.update(extra)
383
+ return body
384
+
385
+
386
+ def request(
387
+ query: str,
388
+ *,
389
+ model: str | None = None,
390
+ base_url: str | None = None,
391
+ answer_format: str | None = None,
392
+ platform: str | None = None,
393
+ system_prompt: str | None = None,
394
+ thinking_effort: str | None = None,
395
+ thinking_field: str | None = None,
396
+ max_tokens: int | None = None,
397
+ temperature: float | None = None,
398
+ timeout: float | None = None,
399
+ max_elapsed: float | None = None,
400
+ extra: dict[str, Any] | None = None,
401
+ ) -> dict[str, Any]:
402
+ key = _api_key()
403
+ resolved_model = _model(model)
404
+ resolved_base_url = _base_url(base_url)
405
+ resolved_answer_format = _answer_format(answer_format)
406
+ resolved_timeout = _timeout(timeout)
407
+ resolved_max_elapsed = _max_elapsed(max_elapsed)
408
+ body = _build_body(
409
+ query,
410
+ model=resolved_model,
411
+ answer_format=resolved_answer_format,
412
+ platform=platform,
413
+ system_prompt=system_prompt,
414
+ thinking_effort=thinking_effort,
415
+ thinking_field=thinking_field,
416
+ max_tokens=max_tokens,
417
+ temperature=temperature,
418
+ extra=extra,
419
+ )
420
+
421
+ url = _chat_completions_url(resolved_base_url)
422
+ headers = {
423
+ "Authorization": f"Bearer {key}",
424
+ "Content-Type": "application/json",
425
+ "Accept": "text/event-stream",
426
+ "User-Agent": "pi-search-services-skill/1.0",
427
+ }
428
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8")
429
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
430
+ started = time.monotonic()
431
+ try:
432
+ with urllib.request.urlopen(req, timeout=resolved_timeout) as resp:
433
+ collected = collect_stream(
434
+ resp, started=started, max_elapsed=resolved_max_elapsed
435
+ )
436
+ if not collected["answer"]:
437
+ raise ServiceError(
438
+ "OpenAI Chat 返回为空",
439
+ status=getattr(resp, "status", None),
440
+ details={
441
+ "chunkCount": collected["chunkCount"],
442
+ "parseErrors": collected["parseErrors"],
443
+ },
444
+ )
445
+ return {
446
+ "ok": True,
447
+ "service": "openai-chat",
448
+ "status": getattr(resp, "status", None),
449
+ "data": {
450
+ "answer": collected["answer"],
451
+ "answerFormat": resolved_answer_format,
452
+ "model": collected["model"] or resolved_model,
453
+ "finishReason": collected["finishReason"],
454
+ "usage": collected["usage"],
455
+ "stream": True,
456
+ "chunkCount": collected["chunkCount"],
457
+ "ignoredReasoning": collected["ignoredReasoning"],
458
+ "parseErrors": collected["parseErrors"],
459
+ },
460
+ "elapsedSeconds": round(time.monotonic() - started, 3),
461
+ }
462
+ except urllib.error.HTTPError as exc:
463
+ details = _redact_value(_decode_response(exc.read()), key)
464
+ raise ServiceError(
465
+ f"OpenAI Chat HTTP {exc.code}", status=exc.code, details=details
466
+ ) from exc
467
+ except urllib.error.URLError as exc:
468
+ raise ServiceError(
469
+ f"OpenAI Chat 网络错误: {exc.reason}", details=_redact_value(str(exc), key)
470
+ ) from exc
471
+ except TimeoutError as exc:
472
+ raise ServiceError(
473
+ f"OpenAI Chat 空闲超时: {resolved_timeout:g} 秒内未收到新数据",
474
+ details=str(exc),
475
+ ) from exc
476
+
477
+
478
+ def search(query: str, **kwargs: Any) -> dict[str, Any]:
479
+ """对外搜索接口,保持输入 query、输出结构化结果。"""
480
+
481
+ return request(query, **kwargs)
482
+
483
+
484
+ def _print_json(value: Any) -> None:
485
+ print(json.dumps(value, ensure_ascii=False, indent=2))
486
+
487
+
488
+ def main(argv: list[str] | None = None) -> int:
489
+ parser = argparse.ArgumentParser(
490
+ description="OpenAI-compatible Chat Completions search helper"
491
+ )
492
+ sub = parser.add_subparsers(dest="operation", required=True)
493
+
494
+ p = sub.add_parser(
495
+ "search", help="通过 OpenAI-compatible Chat Completions 生成搜索式回答"
496
+ )
497
+ p.add_argument("--query", required=True)
498
+ p.add_argument("--model", help="模型名;也可用 SEARCH_OPENAI_MODEL,未配置则失败")
499
+ p.add_argument(
500
+ "--base-url",
501
+ help="OpenAI-compatible base URL;默认 SEARCH_OPENAI_BASE_URL 或 https://api.openai.com/v1",
502
+ )
503
+ p.add_argument(
504
+ "--thinking-effort",
505
+ choices=["low", "medium", "high"],
506
+ help="思考程度;会写入 thinking 字段,字段名默认 reasoning_effort",
507
+ )
508
+ p.add_argument(
509
+ "--thinking-field",
510
+ help="思考程度请求字段名;默认 SEARCH_OPENAI_THINKING_FIELD 或 reasoning_effort;传空字符串可禁用",
511
+ )
512
+ p.add_argument(
513
+ "--answer-format",
514
+ default=None,
515
+ choices=["markdown", "text", "json"],
516
+ help="回答格式;默认 SEARCH_OPENAI_ANSWER_FORMAT 或 markdown",
517
+ )
518
+ p.add_argument("--platform", help="优先关注的平台、站点或来源类型;会写入 user message")
519
+ p.add_argument(
520
+ "--system-prompt",
521
+ help="仅当用户显式需要时才发送的真实 system prompt;默认搜索提示词不会放在 system role",
522
+ )
523
+ p.add_argument("--max-tokens", type=int)
524
+ p.add_argument("--temperature", type=float)
525
+ p.add_argument(
526
+ "--timeout",
527
+ type=float,
528
+ help="连接/空闲超时秒数;持续收到流式 chunk 时会自动延长",
529
+ )
530
+ p.add_argument("--max-elapsed", type=float, help="硬上限秒数;0 表示不设置硬上限")
531
+ p.add_argument("--extra-json", help="合并到请求体的额外 JSON")
532
+
533
+ args = parser.parse_args(argv)
534
+ try:
535
+ if args.operation == "search":
536
+ out = search(
537
+ args.query,
538
+ model=args.model,
539
+ base_url=args.base_url,
540
+ answer_format=args.answer_format,
541
+ platform=args.platform,
542
+ system_prompt=args.system_prompt,
543
+ thinking_effort=args.thinking_effort,
544
+ thinking_field=args.thinking_field,
545
+ max_tokens=args.max_tokens,
546
+ temperature=args.temperature,
547
+ timeout=args.timeout,
548
+ max_elapsed=args.max_elapsed,
549
+ extra=_json_or_none(args.extra_json),
550
+ )
551
+ else:
552
+ parser.error("未知操作")
553
+ return 2
554
+ _print_json(out)
555
+ return 0
556
+ except ServiceError as exc:
557
+ _print_json(
558
+ {
559
+ "ok": False,
560
+ "service": "openai-chat",
561
+ "error": str(exc),
562
+ "status": exc.status,
563
+ "details": exc.details,
564
+ }
565
+ )
566
+ return 1
567
+
568
+
569
+ if __name__ == "__main__":
570
+ sys.exit(main())