dsh-cloudq 0.1.0

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,765 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 腾讯云智能顾问 CloudQ SSE 流式调用脚本
4
+
5
+ 通过 TC3-HMAC-SHA256 签名调用 CloudQ 对话接口。
6
+ 支持 AK/SK、OAuth 和 Connector 三种鉴权方式,统一使用 CloudQChatCompletions 接口。
7
+
8
+ 支持同步(SSE 流式)和异步两种对话模式:
9
+ - 同步(Async=false,默认):SSE 流式返回,连接保持到对话结束
10
+ - 异步(Async=true):立即返回 accepted 帧,通过 DescribeCloudQAsyncTask 轮询结果
11
+
12
+ 接口固定参数:
13
+ service: advisor
14
+ host: advisor.ai.tencentcloudapi.com
15
+ action: CloudQChatCompletions
16
+ version: 2020-07-21
17
+
18
+ 请求格式:
19
+ {"SessionID":"<uuid>","Question":"...","Source":"<platform>","Async":false}
20
+ 异步模式: {"SessionID":"<uuid>","Question":"...","Source":"<platform>","Async":true}
21
+
22
+ 响应格式(SSE 大驼峰字段):
23
+ event:<chat_id>
24
+ data:{"SessionId":"...","ChatId":"...","Event":"content","Content":"...","IsFinal":false}
25
+
26
+ 异步 accepted 帧:
27
+ event: accepted
28
+ data:{"ChatId":"<异步任务ID>","SessionId":"<会话ID>","Content":"任务已受理..."}
29
+
30
+ 纯 Python 标准库实现,无外部依赖。
31
+
32
+ 会话管理:
33
+ SessionID 必填模式:
34
+ - 调用方在首次调用前通过 Python 生成 UUID:python3 -c "import uuid; print(uuid.uuid4())"
35
+ - 每次调用必须通过 --session-id 传入
36
+ - 同一会话使用相同 session_id,新会话生成新 UUID
37
+ - 不同会话使用不同 session_id,支持多会话并行
38
+
39
+ 用法 (命令行):
40
+ # 同步模式(默认)
41
+ python3 tcloud_sse_api.py <question> --source <platform> --session-id <uuid>
42
+ # 异步模式
43
+ python3 tcloud_sse_api.py <question> --source <platform> --session-id <uuid> --async
44
+
45
+ 示例:
46
+ python3 tcloud_sse_api.py '列出架构图' --source codebuddy --session-id 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
47
+ python3 tcloud_sse_api.py '详细说说' --source codebuddy --session-id 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
48
+
49
+ 兼容旧用法(仍支持位置参数传入 session_id,但不推荐):
50
+ python3 tcloud_sse_api.py '列出架构图' <session_id> [source]
51
+
52
+ 作为模块导入:
53
+ from tcloud_sse_api import call_sse_api, generate_session_id
54
+ session_id = generate_session_id()
55
+ result = call_sse_api(
56
+ question="列出架构图",
57
+ session_id=session_id,
58
+ on_event=lambda e: print(e["data"].get("Content", ""), end="", flush=True),
59
+ )
60
+ # result["data"]["session_id"] 可用于后续调用
61
+
62
+ 鉴权方式(三选一):
63
+ 方式一:环境变量 AK/SK
64
+ TENCENTCLOUD_SECRET_ID - 腾讯云 SecretId
65
+ TENCENTCLOUD_SECRET_KEY - 腾讯云 SecretKey
66
+ TENCENTCLOUD_TOKEN - 临时密钥 Token(可选)
67
+
68
+ 方式二:OAuth 浏览器授权
69
+ python3 scripts/login.py # 登录后凭证自动保存
70
+
71
+ 方式三:CloudQ Connector 临时密钥(OneId 方案)
72
+ 由 Agent 通过 MCP Tool CloudQConnector_get_available_tmp_secret 获取并写入 credential.json
73
+
74
+ 输出格式(统一 JSON):
75
+ 成功: {"success": true, "action": "...", "data": {...}, "requestId": "..."}
76
+ 失败: {"success": false, "action": "...", "error": {...}, "requestId": "..."}
77
+ """
78
+
79
+ import hashlib
80
+ import hmac
81
+ import json
82
+ import os
83
+ import re
84
+ import ssl
85
+ import subprocess
86
+ import sys
87
+ import time
88
+ import uuid
89
+ from datetime import datetime, timezone
90
+ from pathlib import Path
91
+ from typing import Optional
92
+ import urllib.request
93
+ from urllib.request import Request
94
+ from urllib.error import URLError, HTTPError
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # 固定参数
98
+ # ---------------------------------------------------------------------------
99
+ SERVICE = "advisor"
100
+ HOST = "advisor.ai.tencentcloudapi.com"
101
+ ACTION = "CloudQChatCompletions" # 统一接口,AK/SK 和 OAuth 均使用
102
+ ACTION_CANCEL_CHAT = "CancelCloudQChat" # 取消同步 SSE 编排
103
+ VERSION = "2020-07-21"
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # 会话管理
108
+ # ---------------------------------------------------------------------------
109
+
110
+
111
+ def generate_session_id() -> str:
112
+ """
113
+ 生成新的 SessionID(UUID v4)。
114
+
115
+ Returns:
116
+ str: UUID v4 格式的 SessionID
117
+ """
118
+ return str(uuid.uuid4())
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # 内部工具函数
123
+ # ---------------------------------------------------------------------------
124
+
125
+ def _get_ssl_context():
126
+ """获取 SSL 上下文,兼容各平台 CA 证书差异"""
127
+ try:
128
+ import certifi
129
+ return ssl.create_default_context(cafile=certifi.where())
130
+ except ImportError:
131
+ # certifi 不可用时降级到系统默认 CA(Windows/Linux 系统 CA 通常可用)
132
+ return ssl.create_default_context()
133
+
134
+
135
+ def _sign_tc3(key: bytes, msg: str) -> bytes:
136
+ """TC3 HMAC-SHA256 签名辅助函数"""
137
+ return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
138
+
139
+
140
+ def _make_error(action: str, code: str, message: str, request_id: str = "") -> dict:
141
+ """构造统一错误结果"""
142
+ return {
143
+ "success": False,
144
+ "action": action,
145
+ "error": {"code": code, "message": message},
146
+ "requestId": request_id,
147
+ }
148
+
149
+
150
+ def _make_success(action: str, data: dict, request_id: str) -> dict:
151
+ """构造统一成功结果"""
152
+ return {
153
+ "success": True,
154
+ "action": action,
155
+ "data": data,
156
+ "requestId": request_id,
157
+ }
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # SSE 行解析
162
+ # ---------------------------------------------------------------------------
163
+
164
+ def parse_sse_line(line: str):
165
+ """
166
+ 解析单行 SSE 数据。
167
+
168
+ Returns:
169
+ dict | None:
170
+ - id 行: {"type": "id", "value": "..."}
171
+ - event 行: {"event": "<value>"}
172
+ - data 行(JSON 有效): {"event": "data", "data": {...}}
173
+ - data 行(JSON 无效): {"event": "data", "raw": "..."}
174
+ - 空行/注释行: None
175
+ """
176
+ if not line or line.startswith(":"):
177
+ return None
178
+
179
+ if line.startswith("id:"):
180
+ return {"type": "id", "value": line[3:].strip()}
181
+
182
+ if line.startswith("data:"):
183
+ payload = line[5:].lstrip()
184
+ try:
185
+ return {"event": "data", "data": json.loads(payload)}
186
+ except (json.JSONDecodeError, ValueError):
187
+ return {"event": "data", "raw": payload}
188
+
189
+ if line.startswith("event:"):
190
+ value = line[6:].strip()
191
+ return {"event": value}
192
+
193
+ return None
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # SSE 流式 API 调用
198
+ # ---------------------------------------------------------------------------
199
+
200
+ def call_sse_api(question: str, session_id: str,
201
+ secret_id: str = None, secret_key: str = None,
202
+ token: str = None, region: str = "ap-guangzhou",
203
+ source: str = "", on_event=None,
204
+ async_mode: bool = True) -> dict:
205
+ """
206
+ 调用 CloudQ SSE 流式 API(统一使用 CloudQChatCompletions)。
207
+
208
+ 默认异步模式:立即返回 accepted 帧,连接关闭,
209
+ 后续通过 DescribeCloudQAsyncTask 轮询结果。
210
+ 设置 async_mode=False 可切回同步 SSE 流式模式(不推荐,客户端有 60s 超时风险)。
211
+
212
+ Args:
213
+ question: 用户问题
214
+ session_id: 会话 ID(同一对话必须保持不变)
215
+ secret_id: SecretId,不传则自动获取
216
+ secret_key: SecretKey,不传则自动获取
217
+ token: 临时密钥 Token,不传则自动获取
218
+ region: 地域字符串,默认 ap-guangzhou
219
+ source: 调用来源平台标识(不区分大小写),如 codebuddy、openclaw 等
220
+ on_event: 回调函数(异步模式下 accepted 帧后即返回,回调不会被调用)
221
+ async_mode: 是否异步模式,默认 True(推荐,避免客户端超时)
222
+
223
+ Returns:
224
+ dict: 统一格式的结果字典
225
+ data 含 chat_id, session_id, content, is_accepted
226
+ """
227
+ # ---- 凭证获取 ----
228
+ cred_source = "env" # 默认假设环境变量
229
+ audit_jwt = "" # Connector 模式下的审计 JWT
230
+
231
+ if secret_id and secret_key:
232
+ # 显式传入凭证,使用 AK/SK 模式
233
+ token = token or os.environ.get("TENCENTCLOUD_TOKEN", "")
234
+ else:
235
+ # 通过 credential_manager 统一获取
236
+ try:
237
+ from credential_manager import (
238
+ get_credential, CredentialExpiredError, CredentialNotFoundError,
239
+ )
240
+ cred = get_credential()
241
+ secret_id = cred["secretId"]
242
+ secret_key = cred["secretKey"]
243
+ token = cred.get("token", "")
244
+ cred_source = cred.get("source", "env")
245
+ audit_jwt = cred.get("auditJwt", "") # Connector 模式附带
246
+ except ImportError:
247
+ # credential_manager 不可用,回退到环境变量
248
+ secret_id = os.environ.get("TENCENTCLOUD_SECRET_ID", "")
249
+ secret_key = os.environ.get("TENCENTCLOUD_SECRET_KEY", "")
250
+ token = os.environ.get("TENCENTCLOUD_TOKEN", "")
251
+ except CredentialExpiredError as e:
252
+ return _make_error(
253
+ ACTION, "CredentialExpired",
254
+ f"凭证已过期。{e}"
255
+ )
256
+ except CredentialNotFoundError:
257
+ return _make_error(
258
+ ACTION, "NeedAuth",
259
+ "未找到凭证,请先通过 OAuth 登录、配置 AK/SK 环境变量,"
260
+ "或通过 Connector 获取临时密钥。"
261
+ )
262
+ except Exception as e:
263
+ return _make_error(
264
+ ACTION, "NeedAuth", str(e)
265
+ )
266
+
267
+ if not secret_id or not secret_key:
268
+ return _make_error(
269
+ ACTION, "MissingCredentials",
270
+ "当前授权方式的凭证缺失,无法调用 API。请检查凭证配置是否正确。"
271
+ "如需更换授权方式,请明确告知。"
272
+ )
273
+
274
+ # 统一使用 CloudQChatCompletions 接口(AK/SK、OAuth、Connector 均支持)
275
+ action = ACTION
276
+
277
+ payload = {"Question": question, "SessionID": session_id}
278
+ if async_mode:
279
+ payload["Async"] = True
280
+ # OAuth / Connector 模式下需要标记使用 CloudQ 控制台凭证
281
+ if cred_source in ("oauth", "connector"):
282
+ payload["UseCloudQCredential"] = True
283
+ # Source 字段统一发送(CloudQChatCompletions 三种鉴权方式均支持)
284
+ if source:
285
+ payload["Source"] = source
286
+ payload_str = json.dumps(payload, separators=(",", ":"))
287
+
288
+ # ---- TC3-HMAC-SHA256 签名 ----
289
+ algorithm = "TC3-HMAC-SHA256"
290
+ timestamp = int(time.time())
291
+ date = datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%d")
292
+
293
+ hashed_payload = hashlib.sha256(payload_str.encode("utf-8")).hexdigest()
294
+ canonical_request = (
295
+ f"POST\n/\n\n"
296
+ f"content-type:application/json\n"
297
+ f"host:{HOST}\n"
298
+ f"x-tc-action:{action.lower()}\n\n"
299
+ f"content-type;host;x-tc-action\n"
300
+ f"{hashed_payload}"
301
+ )
302
+
303
+ credential_scope = f"{date}/{SERVICE}/tc3_request"
304
+ hashed_cr = hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()
305
+ string_to_sign = f"{algorithm}\n{timestamp}\n{credential_scope}\n{hashed_cr}"
306
+
307
+ secret_date = _sign_tc3(f"TC3{secret_key}".encode("utf-8"), date)
308
+ secret_service = _sign_tc3(secret_date, SERVICE)
309
+ secret_signing = _sign_tc3(secret_service, "tc3_request")
310
+ signature = hmac.new(
311
+ secret_signing, string_to_sign.encode("utf-8"), hashlib.sha256
312
+ ).hexdigest()
313
+
314
+ authorization = (
315
+ f"{algorithm} "
316
+ f"Credential={secret_id}/{credential_scope}, "
317
+ f"SignedHeaders=content-type;host;x-tc-action, "
318
+ f"Signature={signature}"
319
+ )
320
+
321
+ headers = {
322
+ "Authorization": authorization,
323
+ "Content-Type": "application/json",
324
+ "Accept": "text/event-stream",
325
+ "Host": HOST,
326
+ "X-TC-Action": action,
327
+ "X-TC-Timestamp": str(timestamp),
328
+ "X-TC-Version": VERSION,
329
+ "X-TC-Region": region,
330
+ }
331
+ if token:
332
+ headers["X-TC-Token"] = token
333
+
334
+ req = Request(
335
+ f"https://{HOST}", data=payload_str.encode("utf-8"),
336
+ headers=headers, method="POST",
337
+ )
338
+
339
+ # ---- 发送请求并解析 SSE 流 ----
340
+ try:
341
+ ctx = _get_ssl_context()
342
+ resp = urllib.request.urlopen(req, context=ctx, timeout=1200)
343
+ except HTTPError as e:
344
+ return _handle_http_error(e, action)
345
+ except URLError as e:
346
+ return _make_error(
347
+ action, "NetworkError",
348
+ f"网络连接失败,请检查网络和域名 {HOST} 是否可达: {e.reason}"
349
+ )
350
+ except Exception as e:
351
+ return _make_error(action, "NetworkError", f"请求异常: {e}")
352
+
353
+ # 检查响应类型:腾讯云 API 可能返回 HTTP 200 + JSON 错误体(非 SSE 流)
354
+ content_type = resp.headers.get("Content-Type", "")
355
+ if "text/event-stream" not in content_type:
356
+ return _handle_non_sse_response(resp, action)
357
+
358
+ return _parse_sse_stream(resp, on_event, action, cred_source, session_id, async_mode)
359
+
360
+
361
+ def _parse_sse_stream(resp, on_event, action: str, cred_source: str = "env",
362
+ session_id: str = "", async_mode: bool = True) -> dict:
363
+ """
364
+ 解析 CloudQ SSE 流并构建结果。
365
+
366
+ 响应字段为大驼峰:Content, IsFinal, ChatId, SessionId, Event, Error
367
+ 输出统一为小写字段名的结果 dict。
368
+
369
+ SSE 流中可能包含错误事件(如权限不足),Error 字段格式:
370
+ {"Code": "UnauthorizedOperation", "Message": "..."}
371
+
372
+ 异步模式(async_mode=True):
373
+ 解析 Event="accepted" 的 data 帧,提取 ChatID + SessionID 后立即返回。
374
+ """
375
+ content_parts = []
376
+ last_event_data = {}
377
+ request_id = ""
378
+
379
+ for raw_line in resp:
380
+ line = raw_line.decode("utf-8").rstrip("\r\n")
381
+
382
+ parsed = parse_sse_line(line)
383
+ if parsed is None:
384
+ continue
385
+
386
+ if parsed.get("event") != "data":
387
+ continue
388
+
389
+ data = parsed.get("data")
390
+ if not isinstance(data, dict):
391
+ continue
392
+
393
+ # ---- 异步模式:处理 Event="accepted" 帧 ----
394
+ if async_mode and data.get("Event") == "accepted":
395
+ chat_id = data.get("ChatId", "")
396
+ resp_session_id = data.get("SessionId", session_id)
397
+ content = data.get("Content", "")
398
+ return _make_success(action, {
399
+ "chat_id": chat_id,
400
+ "session_id": resp_session_id,
401
+ "content": content,
402
+ "is_accepted": True,
403
+ }, chat_id)
404
+
405
+ if not request_id:
406
+ request_id = data.get("ChatId", "")
407
+
408
+ # 检查 SSE 流中的错误事件
409
+ error_info = data.get("Error")
410
+ if isinstance(error_info, dict) and error_info.get("Code"):
411
+ return _make_error(
412
+ action, error_info.get("Code", "Unknown"),
413
+ error_info.get("Message", "未知错误"),
414
+ request_id,
415
+ )
416
+
417
+ if on_event:
418
+ on_event(parsed)
419
+
420
+ content = data.get("Content", "")
421
+ if content:
422
+ content_parts.append(content)
423
+ last_event_data = data
424
+
425
+ # IsFinal=True 表示后端已推送完毕,主动断开不再等待
426
+ if data.get("IsFinal") is True:
427
+ break
428
+
429
+ raw_content = "".join(content_parts)
430
+ # 免密链接替换仅在 AK/SK 模式下执行(login_url.py 需要 sts:AssumeRole 权限,OAuth/Connector 没有)
431
+ if cred_source not in ("oauth", "connector"):
432
+ processed = _replace_console_urls(raw_content)
433
+ processed = _ensure_login_url(processed)
434
+ else:
435
+ processed = raw_content
436
+ # OAuth / Connector 模式下检测"未配置凭证"提示,引导用户到 CloudQ 控制台配置
437
+ if cred_source in ("oauth", "connector") and _is_credential_not_configured(processed):
438
+ processed += (
439
+ "\n\n请前往 [CloudQ 控制台](https://console.cloud.tencent.com/advisor/cloudq) "
440
+ "完成凭证配置后再使用。"
441
+ )
442
+
443
+ merged = {
444
+ "session_id": session_id,
445
+ "content": processed,
446
+ "is_final": last_event_data.get("IsFinal", True),
447
+ }
448
+
449
+ return _make_success(action, merged, request_id)
450
+
451
+
452
+ # ---------------------------------------------------------------------------
453
+ # CloudQ 凭证未配置检测(OAuth / Connector 模式下适用)
454
+ # ---------------------------------------------------------------------------
455
+
456
+ # CloudQChatCompletions 接口在用户未配置 CloudQ 凭证时返回的提示关键词
457
+ _CRED_NOT_CONFIGURED_KEYWORDS = [
458
+ "尚未配置腾讯云凭证",
459
+ "未配置腾讯云凭证",
460
+ "凭证设置",
461
+ "前往凭证设置",
462
+ ]
463
+
464
+
465
+ def _is_credential_not_configured(content: str) -> bool:
466
+ """检测 CloudQChatCompletions 返回的内容是否为"未配置凭证"提示"""
467
+ if not content:
468
+ return False
469
+ return any(kw in content for kw in _CRED_NOT_CONFIGURED_KEYWORDS)
470
+
471
+
472
+ # ---------------------------------------------------------------------------
473
+ # 控制台链接 → 免密登录链接替换
474
+ # ---------------------------------------------------------------------------
475
+
476
+ # 匹配 console.cloud.tencent.com 的 URL(含路径和查询参数)
477
+ _CONSOLE_URL_RE = re.compile(r'https://console\.cloud\.tencent\.com[^\s\)\]"\']*')
478
+ # 提取 content 中的 archId(arch-开头)
479
+ _ARCH_ID_RE = re.compile(r'\barch-[a-z0-9]+\b')
480
+ # 免密登录链接特征(已替换过的不再处理)
481
+ _LOGIN_URL_MARKER = "cloud.tencent.com/login/roleAccessCallback"
482
+ # 不生成免密登录链接的路径(advisor/cloudq 需用户自行登录)
483
+ _SKIP_LOGIN_PATHS = re.compile(r'https://console\.cloud\.tencent\.com/advisor/cloudq(\?|/|$)')
484
+
485
+ # login_url.py 脚本路径
486
+ _LOGIN_SCRIPT = Path(__file__).resolve().parent / "login_url.py"
487
+
488
+
489
+ def _generate_login_url(target_url: str) -> Optional[str]:
490
+ """调用 login_url.py 生成免密登录链接,失败返回 None"""
491
+ try:
492
+ result = subprocess.run(
493
+ [sys.executable, str(_LOGIN_SCRIPT), target_url],
494
+ capture_output=True, text=True, timeout=30,
495
+ )
496
+ if result.returncode != 0:
497
+ return None
498
+ data = json.loads(result.stdout.strip())
499
+ if data.get("success"):
500
+ return data["data"]["loginUrl"]
501
+ except Exception:
502
+ pass
503
+ return None
504
+
505
+
506
+ def _append_hide_nav(url: str) -> str:
507
+ """为控制台 URL 追加 hideTopNav=true 参数"""
508
+ if "hideTopNav=true" in url:
509
+ return url
510
+ sep = "&" if "?" in url else "?"
511
+ return f"{url}{sep}hideTopNav=true"
512
+
513
+
514
+ def _enrich_url_with_arch_id(url: str, arch_id: str) -> str:
515
+ """如果 URL 不含 archId 参数,自动追加第一个 archId"""
516
+ if "archId=" in url or not arch_id:
517
+ return url
518
+ sep = "&" if "?" in url else "?"
519
+ return f"{url}{sep}archId={arch_id}"
520
+
521
+
522
+ def _extract_first_arch_id(content: str) -> str:
523
+ """从 content 中提取第一个 archId"""
524
+ m = _ARCH_ID_RE.search(content)
525
+ return m.group(0) if m else ""
526
+
527
+
528
+ def _replace_console_urls(content: str) -> str:
529
+ """
530
+ 扫描 content 中所有控制台链接,替换为免密登录链接。
531
+
532
+ 处理逻辑:
533
+ 1. 跳过已是免密登录链接的 URL
534
+ 2. 如果控制台链接不含 archId,但 content 中有 archId,自动拼入
535
+ 3. 追加 hideTopNav 参数
536
+ 4. 调用 login_url.py 生成免密链接替换
537
+ 5. 生成失败时保留原链接
538
+ """
539
+ if "console.cloud.tencent.com" not in content:
540
+ return content
541
+ urls = _CONSOLE_URL_RE.findall(content)
542
+ if not urls:
543
+ return content
544
+
545
+ first_arch_id = _extract_first_arch_id(content)
546
+
547
+ # 去重保序
548
+ seen = set()
549
+ unique_urls = []
550
+ for u in urls:
551
+ if u not in seen:
552
+ seen.add(u)
553
+ unique_urls.append(u)
554
+
555
+ for raw_url in unique_urls:
556
+ if _LOGIN_URL_MARKER in raw_url:
557
+ continue
558
+ if _SKIP_LOGIN_PATHS.match(raw_url):
559
+ continue
560
+ target = _append_hide_nav(raw_url)
561
+ target = _enrich_url_with_arch_id(target, first_arch_id)
562
+ login_url = _generate_login_url(target)
563
+ if login_url:
564
+ content = content.replace(raw_url, login_url)
565
+ return content
566
+
567
+
568
+ def _is_advisor_content(content: str) -> bool:
569
+ """判断内容是否属于智能顾问场景(架构图、评估、巡检等)"""
570
+ advisor_keywords = [
571
+ "架构图", "架构目录", "架构详情", "架构评估", "风险评估",
572
+ "巡检", "智能顾问", "advisor", "ArchId", "archId",
573
+ "arch-", "评估项", "评估结果", "扫描", "架构健康",
574
+ ]
575
+ lower = content.lower()
576
+ return any(kw.lower() in lower for kw in advisor_keywords)
577
+
578
+
579
+ def _ensure_login_url(content: str) -> str:
580
+ """
581
+ 确保 content 中包含免密登录链接。
582
+ 如果 content 不含任何免密链接,自动生成一个并追加到末尾。
583
+
584
+ 排除规则:
585
+ - content 中已有 advisor/cloudq 链接(无需免密)则跳过
586
+ 场景判断:
587
+ - 智能顾问场景: advisor?hideTopNav=true(有 archId 时追加)
588
+ - 非智能顾问场景: https://console.cloud.tencent.com/
589
+ """
590
+ if not content or _LOGIN_URL_MARKER in content:
591
+ return content
592
+
593
+ # 已包含 advisor/cloudq 链接则跳过(该页面不需要免密)
594
+ if "console.cloud.tencent.com/advisor/cloudq" in content:
595
+ return content
596
+
597
+ first_arch_id = _extract_first_arch_id(content)
598
+
599
+ if _is_advisor_content(content) or first_arch_id:
600
+ base = "https://console.cloud.tencent.com/advisor?hideTopNav=true"
601
+ if first_arch_id:
602
+ target = f"{base}&archId={first_arch_id}"
603
+ else:
604
+ target = base
605
+ label = "前往智能顾问控制台"
606
+ else:
607
+ target = "https://console.cloud.tencent.com/"
608
+ label = "前往腾讯云控制台"
609
+
610
+ login_url = _generate_login_url(target)
611
+ if login_url:
612
+ content += f"\n\n[{label}]({login_url})"
613
+ return content
614
+
615
+
616
+ def _handle_http_error(e: HTTPError, action: str) -> dict:
617
+ """处理 HTTP 错误响应"""
618
+ try:
619
+ body = e.read().decode("utf-8")
620
+ data = json.loads(body)
621
+ response = data.get("Response", {})
622
+ error = response.get("Error", {})
623
+ if error:
624
+ return _make_error(
625
+ action, error.get("Code", "HTTPError"),
626
+ error.get("Message", f"HTTP {e.code}"),
627
+ response.get("RequestId", ""),
628
+ )
629
+ except Exception:
630
+ pass
631
+ return _make_error(
632
+ action, "HTTPError",
633
+ f"HTTP 请求失败 (状态码 {e.code}): {e.reason}"
634
+ )
635
+
636
+
637
+ def _handle_non_sse_response(resp, action: str) -> dict:
638
+ """处理非 SSE 响应(HTTP 200 但 Content-Type 非 event-stream,通常是 JSON 错误体)"""
639
+ try:
640
+ body = resp.read().decode("utf-8")
641
+ data = json.loads(body)
642
+ response = data.get("Response", {})
643
+ error = response.get("Error", {})
644
+ if error:
645
+ return _make_error(
646
+ action, error.get("Code", "Unknown"),
647
+ error.get("Message", "未知错误"),
648
+ response.get("RequestId", ""),
649
+ )
650
+ # 非错误但也非 SSE,返回原始数据
651
+ return _make_success(action, response, response.get("RequestId", ""))
652
+ except (json.JSONDecodeError, ValueError):
653
+ return _make_error(
654
+ action, "InvalidResponse",
655
+ f"接口返回非 SSE 格式且无法解析为 JSON: {body[:200]}"
656
+ )
657
+ except Exception as e:
658
+ return _make_error(action, "InvalidResponse", f"解析响应失败: {e}")
659
+
660
+
661
+ # ---------------------------------------------------------------------------
662
+ # 命令行入口
663
+ # ---------------------------------------------------------------------------
664
+
665
+ def _output_json(obj: dict) -> str:
666
+ return json.dumps(obj, ensure_ascii=False)
667
+
668
+
669
+ def _parse_args(args: list) -> dict:
670
+ """解析命令行参数,支持 --source / --session-id 标志位。
671
+
672
+ Returns:
673
+ dict: {
674
+ "question": str,
675
+ "session_id": str | None, # 传入时非 None
676
+ "source": str,
677
+ }
678
+ """
679
+ if not args:
680
+ return {}
681
+
682
+ result = {
683
+ "question": "",
684
+ "session_id": None,
685
+ "source": "",
686
+ }
687
+
688
+ # 第一个非标志位参数为 question
689
+ positional = []
690
+ i = 0
691
+ while i < len(args):
692
+ arg = args[i]
693
+ if arg == "--source":
694
+ if i + 1 < len(args):
695
+ result["source"] = args[i + 1]
696
+ i += 1
697
+ elif arg.startswith("--source="):
698
+ result["source"] = arg[len("--source="):]
699
+ elif arg == "--session-id":
700
+ if i + 1 < len(args):
701
+ result["session_id"] = args[i + 1]
702
+ i += 1
703
+ elif arg.startswith("--session-id="):
704
+ result["session_id"] = arg[len("--session-id="):]
705
+ elif not result["question"]:
706
+ result["question"] = arg
707
+ positional.append(arg)
708
+ else:
709
+ positional.append(arg)
710
+ i += 1
711
+
712
+ # 兼容旧用法:位置参数第2个为 session_id,第3个为 source
713
+ if len(positional) >= 2:
714
+ result["session_id"] = positional[1] if positional[1] else None
715
+ if len(positional) >= 3 and not result["source"]:
716
+ result["source"] = positional[2]
717
+
718
+ return result
719
+
720
+
721
+ def main():
722
+ """命令行入口:python3 tcloud_sse_api.py <question> --source <platform> --session-id <uuid>"""
723
+ # 锁定 stdout/stderr 为 UTF-8
724
+ import io
725
+ if hasattr(sys.stdout, "reconfigure"):
726
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
727
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
728
+ else:
729
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
730
+ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
731
+
732
+ parsed = _parse_args(sys.argv[1:])
733
+ if not parsed or not parsed.get("question"):
734
+ usage = (
735
+ "用法: python3 tcloud_sse_api.py <question> --source <platform> "
736
+ "--session-id <uuid>"
737
+ )
738
+ print(_output_json(_make_error(ACTION, "MissingParameter", usage)))
739
+ sys.exit(1)
740
+
741
+ question = parsed["question"]
742
+ source = parsed["source"]
743
+
744
+ # ---- SessionID ----
745
+ if not parsed["session_id"]:
746
+ print(_output_json(_make_error(
747
+ ACTION, "MissingParameter",
748
+ "缺少必填参数 --session-id。"
749
+ "请通过 SID=$(python3 -c 'import uuid;print(uuid.uuid4())') 生成后传入。"
750
+ )))
751
+ sys.exit(1)
752
+ session_id = parsed["session_id"]
753
+
754
+ # 回显 session_id 到 stderr,供调用方追问时复用
755
+ print(f"[session] {session_id}", file=sys.stderr, flush=True)
756
+
757
+ result = call_sse_api(question, session_id, source=source)
758
+
759
+ print(_output_json(result))
760
+ if not result.get("success"):
761
+ sys.exit(1)
762
+
763
+
764
+ if __name__ == "__main__":
765
+ main()