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,437 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ health_check.py — 引擎健康检查
4
+
5
+ 职责:
6
+ - 遍历三层引擎注册表所有引擎
7
+ - 标记可用/降级/不可用
8
+ - 按层级筛选可用引擎
9
+ - 输出健康报告供路由决策使用
10
+
11
+ 调用方式:
12
+ - 内部:from health_check import health_check_all, get_available_engines_by_tier
13
+ - CLI:python3 health_check.py [--tier T1] [--json]
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+ import time
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ # 添加 scripts 目录到 import 路径
26
+ SCRIPT_DIR = Path(__file__).resolve().parent.parent / "scripts"
27
+ sys.path.insert(0, str(SCRIPT_DIR))
28
+
29
+ try:
30
+ import yaml
31
+ except ImportError:
32
+ yaml = None # type: ignore
33
+
34
+
35
+ REGISTRY_PATH = Path(__file__).resolve().parent.parent / "backends" / "engine_registry.yaml"
36
+
37
+ # 本地搜索模块路径(优雅降级,缺失时不阻断)
38
+ # 默认路径为项目内 local-search/ 子目录,可通过环境变量 LOCAL_SEARCH_PATH 覆盖
39
+ _LOCAL_SEARCH_V3_PATH = Path(os.environ.get(
40
+ "LOCAL_SEARCH_PATH",
41
+ str(Path(__file__).resolve().parent.parent / "local-search" / "search_v3.py")
42
+ ))
43
+ _SEARXNG_BRIDGE_PATH = Path(os.environ.get(
44
+ "SEARXNG_BRIDGE_PATH",
45
+ str(Path(__file__).resolve().parent.parent / "local-search" / "searxng_bridge.py")
46
+ ))
47
+
48
+
49
+ def _load_registry() -> list[dict[str, Any]]:
50
+ """加载引擎注册表 YAML。"""
51
+ if not REGISTRY_PATH.exists():
52
+ return []
53
+
54
+ if yaml is None:
55
+ # 无 PyYAML 时尝试简易解析(仅处理基本结构)
56
+ return _parse_yaml_light()
57
+
58
+ try:
59
+ with open(REGISTRY_PATH, "r", encoding="utf-8") as f:
60
+ data = yaml.safe_load(f)
61
+ except Exception:
62
+ return []
63
+
64
+ if not isinstance(data, dict):
65
+ return []
66
+
67
+ return data.get("engines", [])
68
+
69
+
70
+ def _parse_yaml_light() -> list[dict[str, Any]]:
71
+ """简易 YAML 解析器回退 — 仅提取 engines 列表。"""
72
+ try:
73
+ text = REGISTRY_PATH.read_text(encoding="utf-8")
74
+ # 查找 engines: 段落后面的条目
75
+ # 这是一个非常基础的解析,PyYAML 不可用时才使用
76
+ return []
77
+ except Exception:
78
+ return []
79
+
80
+
81
+ def _check_t1_engine(name: str, timeout: float = 5) -> dict[str, Any]:
82
+ """检查 T1 引擎健康状态。
83
+
84
+ 通过 engines.py 的 search() 函数快速探测。
85
+ """
86
+ result = {
87
+ "engine": name,
88
+ "tier": "T1",
89
+ "available": False,
90
+ "latency_ms": 0,
91
+ "error": None,
92
+ "status": "unavailable",
93
+ }
94
+
95
+ try:
96
+ from engines import search, get_registry
97
+ registry = get_registry()
98
+ if name not in registry:
99
+ result["error"] = f"引擎 {name} 未在 engines.py 注册表中"
100
+ result["status"] = "unavailable"
101
+ return result
102
+
103
+ fn = registry[name]
104
+ t0 = time.time()
105
+ # 快速探测:只请求 1 条结果
106
+ try:
107
+ results = fn("test", n=1, _timeout=timeout)
108
+ except TypeError:
109
+ results = fn("test", n=1)
110
+
111
+ elapsed = round((time.time() - t0) * 1000)
112
+ result["latency_ms"] = elapsed
113
+
114
+ if results and isinstance(results, list) and len(results) > 0:
115
+ result["available"] = True
116
+ result["status"] = "ok"
117
+ result["sample"] = results[0]
118
+ else:
119
+ result["error"] = "返回空结果"
120
+ result["status"] = "degraded"
121
+
122
+ except ImportError:
123
+ result["error"] = "engines.py 不可导入"
124
+ result["status"] = "unavailable"
125
+ except Exception as e:
126
+ result["error"] = f"{type(e).__name__}: {e}"
127
+ result["status"] = "unavailable"
128
+
129
+ return result
130
+
131
+
132
+ def _check_t2_engine(name: str, timeout: float = 5) -> dict[str, Any]:
133
+ """检查 T2 引擎健康状态。
134
+
135
+ 通过 search_v3.check_engine_health() 探测。
136
+ 引擎名去掉 local/ 前缀后传给 check_engine_health。
137
+ """
138
+ local_name = name.replace("local/", "") if name.startswith("local/") else name
139
+
140
+ result = {
141
+ "engine": name,
142
+ "tier": "T2",
143
+ "available": False,
144
+ "latency_ms": 0,
145
+ "error": None,
146
+ "status": "unavailable",
147
+ }
148
+
149
+ try:
150
+ sys.path.insert(0, str(_LOCAL_SEARCH_V3_PATH.parent))
151
+ from search_v3 import check_engine_health
152
+ health = check_engine_health(local_name, timeout=timeout)
153
+ result["available"] = health.get("available", False)
154
+ result["latency_ms"] = health.get("latency_ms", 0)
155
+ result["error"] = health.get("error")
156
+ result["status"] = health.get("status", "unavailable")
157
+ if health.get("sample"):
158
+ result["sample"] = health["sample"]
159
+ except ImportError:
160
+ result["error"] = "search_v3.py 不可导入"
161
+ result["status"] = "unavailable"
162
+ except Exception as e:
163
+ result["error"] = f"{type(e).__name__}: {e}"
164
+ result["status"] = "unavailable"
165
+
166
+ return result
167
+
168
+
169
+ def _check_t3_engine(name: str, timeout: float = 5) -> dict[str, Any]:
170
+ """检查 T3 引擎健康状态。
171
+
172
+ 通过 searxng_bridge 检查 SearXNG 是否可达,然后检查引擎是否在列表中。
173
+ """
174
+ result = {
175
+ "engine": name,
176
+ "tier": "T3",
177
+ "available": False,
178
+ "latency_ms": 0,
179
+ "error": None,
180
+ "status": "unavailable",
181
+ }
182
+
183
+ try:
184
+ sys.path.insert(0, str(_SEARXNG_BRIDGE_PATH.parent))
185
+ from searxng_bridge import fetch_searxng_engine_list
186
+
187
+ t0 = time.time()
188
+ engine_data = fetch_searxng_engine_list(timeout=timeout)
189
+ elapsed = round((time.time() - t0) * 1000)
190
+ result["latency_ms"] = elapsed
191
+
192
+ if engine_data.get("error"):
193
+ result["error"] = engine_data["error"]
194
+ result["status"] = "unavailable"
195
+ return result
196
+
197
+ # 检查指定引擎是否在垂直可用列表中
198
+ searxng_engine_name = name.replace("searxng/", "")
199
+ available_verticals = engine_data.get("available_vertical", [])
200
+ available_names = {e.get("name", "").lower() for e in available_verticals}
201
+
202
+ if searxng_engine_name.lower() in available_names or \
203
+ searxng_engine_name.lower().replace("_", " ") in available_names:
204
+ result["available"] = True
205
+ result["status"] = "ok"
206
+ else:
207
+ # 也检查是否在 category_map 中
208
+ cmap = engine_data.get("category_map", {})
209
+ found = False
210
+ for cat_engines in cmap.values():
211
+ if searxng_engine_name.lower() in [e.lower() for e in cat_engines]:
212
+ found = True
213
+ break
214
+ if found:
215
+ result["available"] = True
216
+ result["status"] = "ok"
217
+ else:
218
+ result["error"] = f"引擎 {searxng_engine_name} 不在 SearXNG 可用垂直列表中"
219
+ result["status"] = "degraded"
220
+
221
+ except ImportError:
222
+ result["error"] = "searxng_bridge.py 不可导入"
223
+ result["status"] = "unavailable"
224
+ except Exception as e:
225
+ result["error"] = f"{type(e).__name__}: {e}"
226
+ result["status"] = "unavailable"
227
+
228
+ return result
229
+
230
+
231
+ # 健康检查映射
232
+ _CHECKERS = {
233
+ "T1": _check_t1_engine,
234
+ "T2": _check_t2_engine,
235
+ "T3": _check_t3_engine,
236
+ }
237
+
238
+
239
+ def health_check_all(
240
+ timeout: float = 5,
241
+ tiers: list[str] | None = None,
242
+ fast: bool = False,
243
+ ) -> dict[str, Any]:
244
+ """遍历注册表所有引擎,标记可用/降级/不可用。
245
+
246
+ Args:
247
+ timeout: 单引擎探测超时(秒)
248
+ tiers: 要检查的层级列表(None = 全部)
249
+ fast: 快速模式 — 仅检查推荐引擎
250
+
251
+ Returns:
252
+ {
253
+ "ok": [...],
254
+ "degraded": [...],
255
+ "unavailable": [...],
256
+ "by_tier": {"T1": [...], "T2": [...], "T3": [...]},
257
+ "summary": {"total": N, "ok_count": N, "degraded_count": N, ...},
258
+ "elapsed_ms": N,
259
+ }
260
+ """
261
+ t0_total = time.time()
262
+
263
+ registry = _load_registry()
264
+ if not registry:
265
+ return {
266
+ "ok": [], "degraded": [], "unavailable": [],
267
+ "by_tier": {}, "summary": {"total": 0, "ok_count": 0,
268
+ "degraded_count": 0, "unavailable_count": 0},
269
+ "elapsed_ms": 0, "error": "引擎注册表为空或不可读",
270
+ }
271
+
272
+ target_tiers = set(tiers) if tiers else {"T1", "T2", "T3"}
273
+
274
+ ok_list: list[dict] = []
275
+ degraded_list: list[dict] = []
276
+ unavailable_list: list[dict] = []
277
+ by_tier: dict[str, list[dict]] = {"T1": [], "T2": [], "T3": []}
278
+
279
+ for engine in registry:
280
+ name = engine.get("name", "")
281
+ tier = engine.get("tier", "")
282
+ status = engine.get("status", "unknown")
283
+
284
+ if tier not in target_tiers:
285
+ continue
286
+
287
+ # 快速模式:仅检查推荐引擎 + 状态为 ok 的引擎
288
+ if fast:
289
+ is_recommended = engine.get("recommended", False)
290
+ is_ok = status == "ok"
291
+ if not (is_recommended or is_ok):
292
+ engine["_checked"] = False
293
+ engine["_skip_reason"] = "非推荐引擎(快速模式)"
294
+ continue
295
+
296
+ # 如果注册表已标记为 disabled,直接跳过
297
+ if status == "disabled":
298
+ engine["_checked"] = False
299
+ engine["_skip_reason"] = "已禁用"
300
+ continue
301
+
302
+ # 对于 degraded 状态的引擎(如 Baidu),不做探测直接使用注册表状态
303
+ if status == "degraded":
304
+ check_result = {
305
+ "engine": name,
306
+ "tier": tier,
307
+ "available": False,
308
+ "latency_ms": engine.get("latency_ms", 0),
309
+ "error": engine.get("note", "已降级"),
310
+ "status": "degraded",
311
+ "_checked": True,
312
+ "_from_registry": True,
313
+ }
314
+ degraded_list.append(check_result)
315
+ by_tier.setdefault(tier, []).append(check_result)
316
+ continue
317
+
318
+ # 执行健康检查
319
+ checker = _CHECKERS.get(tier)
320
+ if checker:
321
+ check_result = checker(name, timeout=timeout)
322
+ check_result["_checked"] = True
323
+ else:
324
+ check_result = {
325
+ "engine": name,
326
+ "tier": tier,
327
+ "available": False,
328
+ "latency_ms": 0,
329
+ "error": f"未知层级: {tier}",
330
+ "status": "unavailable",
331
+ "_checked": True,
332
+ }
333
+
334
+ # 分类
335
+ st = check_result.get("status", "unavailable")
336
+ if st == "ok":
337
+ ok_list.append(check_result)
338
+ elif st in ("degraded", "slow"):
339
+ degraded_list.append(check_result)
340
+ else:
341
+ unavailable_list.append(check_result)
342
+
343
+ by_tier.setdefault(tier, []).append(check_result)
344
+
345
+ elapsed = round((time.time() - t0_total) * 1000)
346
+
347
+ return {
348
+ "ok": ok_list,
349
+ "degraded": degraded_list,
350
+ "unavailable": unavailable_list,
351
+ "by_tier": by_tier,
352
+ "summary": {
353
+ "total": len(ok_list) + len(degraded_list) + len(unavailable_list),
354
+ "ok_count": len(ok_list),
355
+ "degraded_count": len(degraded_list),
356
+ "unavailable_count": len(unavailable_list),
357
+ },
358
+ "elapsed_ms": elapsed,
359
+ }
360
+
361
+
362
+ def get_available_engines_by_tier(
363
+ tier: str,
364
+ include_degraded: bool = False,
365
+ ) -> list[str]:
366
+ """按层级筛选可用引擎名称列表。
367
+
368
+ Args:
369
+ tier: 层级名("T1" / "T2" / "T3")
370
+ include_degraded: 是否包含降级但仍可用的引擎
371
+
372
+ Returns:
373
+ 引擎名称列表(仅为 ok 状态,若 include_degraded 则包含 degraded)
374
+ """
375
+ registry = _load_registry()
376
+ names: list[str] = []
377
+
378
+ for engine in registry:
379
+ if engine.get("tier") != tier:
380
+ continue
381
+ status = engine.get("status", "unknown")
382
+ if status == "ok":
383
+ names.append(engine.get("name", ""))
384
+ elif include_degraded and status in ("degraded", "slow"):
385
+ names.append(engine.get("name", ""))
386
+
387
+ return names
388
+
389
+
390
+ # ── CLI 调试用 ─────────────────────────────────────────────────────────────────
391
+ def _cli():
392
+ import argparse
393
+
394
+ parser = argparse.ArgumentParser(description="引擎健康检查")
395
+ parser.add_argument("--tier", "-t", choices=["T1", "T2", "T3"],
396
+ help="仅检查指定层级")
397
+ parser.add_argument("--json", action="store_true", help="JSON 格式输出")
398
+ parser.add_argument("--fast", action="store_true", help="快速模式(仅检查推荐引擎)")
399
+ parser.add_argument("--list", action="store_true", help="列出注册表所有引擎(不探测)")
400
+ args = parser.parse_args()
401
+
402
+ if args.list:
403
+ registry = _load_registry()
404
+ if args.tier:
405
+ registry = [e for e in registry if e.get("tier") == args.tier]
406
+ for e in registry:
407
+ print(f"[{e.get('tier', '?')}] {e.get('name', '?')} "
408
+ f"({e.get('status', '?')}) — {e.get('desc', '')}")
409
+ return
410
+
411
+ tiers = [args.tier] if args.tier else None
412
+ result = health_check_all(timeout=5, tiers=tiers, fast=args.fast)
413
+
414
+ if args.json:
415
+ print(json.dumps(result, ensure_ascii=False, indent=2))
416
+ else:
417
+ print(f"健康检查完成 — 耗时 {result['elapsed_ms']}ms")
418
+ summary = result["summary"]
419
+ print(f" 可用: {summary['ok_count']} 降级: {summary['degraded_count']} "
420
+ f"不可用: {summary['unavailable_count']} 总计: {summary['total']}")
421
+ print()
422
+ if result["ok"]:
423
+ print("✅ 可用:")
424
+ for e in result["ok"]:
425
+ print(f" [{e['tier']}] {e['engine']} ({e['latency_ms']}ms)")
426
+ if result["degraded"]:
427
+ print("⚠️ 降级:")
428
+ for e in result["degraded"]:
429
+ print(f" [{e['tier']}] {e['engine']} — {e.get('error', '')}")
430
+ if result["unavailable"]:
431
+ print("❌ 不可用:")
432
+ for e in result["unavailable"]:
433
+ print(f" [{e['tier']}] {e['engine']} — {e.get('error', '')}")
434
+
435
+
436
+ if __name__ == "__main__":
437
+ _cli()
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ health_probe.py — 引擎健康探针(v2.5 新增)
4
+
5
+ 对 HTTP 引擎做可达性检查,结果写入 SQLite 供 route.py 查询。
6
+ - 启动时全量探测
7
+ - 每 5 分钟后台自动刷新
8
+ - 连续 2 次失败标记 unavailable,1 次成功恢复
9
+
10
+ 用法:
11
+ python3 health_probe.py # 单次探测
12
+ python3 health_probe.py --watch # 后台持续监控(每 5 分钟)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import sqlite3
20
+ import sys
21
+ import threading
22
+ import time
23
+ import urllib.request
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ SCRIPT_DIR = Path(__file__).parent
28
+ sys.path.insert(0, str(SCRIPT_DIR))
29
+
30
+ try:
31
+ from config import load_config, get_engines
32
+ except ImportError:
33
+ from config import load_config, get_engines
34
+
35
+
36
+ # ── 路径 ──────────────────────────────────────────────────────────────────────
37
+
38
+ DB_PATH = Path.home() / ".cache" / "unified-search" / "health.db"
39
+ PROBE_INTERVAL = 300 # 5 分钟
40
+ PROBE_TIMEOUT = 1.5 # 单次探测超时(快,不能拖慢启动)
41
+ MAX_FAILURES = 2 # 连续失败次数阈值
42
+
43
+
44
+ # ── SQLite 存储 ───────────────────────────────────────────────────────────────
45
+
46
+ def _connect():
47
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
48
+ conn = sqlite3.connect(str(DB_PATH), timeout=5)
49
+ conn.execute("PRAGMA journal_mode=WAL")
50
+ return conn
51
+
52
+
53
+ def _init_db():
54
+ with _connect() as conn:
55
+ conn.execute("""
56
+ CREATE TABLE IF NOT EXISTS engine_health (
57
+ engine TEXT PRIMARY KEY,
58
+ status TEXT NOT NULL DEFAULT 'unknown',
59
+ consecutive_failures INTEGER DEFAULT 0,
60
+ last_probe_at REAL DEFAULT 0,
61
+ last_latency_ms REAL DEFAULT 0,
62
+ last_error TEXT DEFAULT ''
63
+ )
64
+ """)
65
+
66
+
67
+ def _probe_http(url: str, timeout: float = PROBE_TIMEOUT) -> tuple[bool, float, str]:
68
+ """探测 HTTP 引擎:发起 HEAD 请求检查可达性。"""
69
+ try:
70
+ req = urllib.request.Request(url, method="HEAD")
71
+ t0 = time.time()
72
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
73
+ elapsed = (time.time() - t0) * 1000
74
+ return True, elapsed, ""
75
+ except Exception as e:
76
+ return False, 0, str(e)
77
+
78
+
79
+ def _probe_cli(cmd: list[str], timeout: float = 2) -> tuple[bool, float, str]:
80
+ """探测 CLI 引擎:检查命令是否存在。"""
81
+ import subprocess
82
+ if not cmd:
83
+ return False, 0, "empty command"
84
+ exe = cmd[0]
85
+ if isinstance(exe, str) and exe.startswith("~"):
86
+ exe = str(Path(exe).expanduser())
87
+ try:
88
+ result = subprocess.run(["which", exe], capture_output=True, text=True, timeout=timeout)
89
+ if result.returncode == 0:
90
+ return True, 0, ""
91
+ return False, 0, f"command not found: {exe}"
92
+ except Exception as e:
93
+ return False, 0, str(e)
94
+
95
+
96
+ def probe_all_engines():
97
+ """探测所有已启用的引擎。"""
98
+ _init_db()
99
+ cfg = load_config()
100
+ engines = get_engines(cfg)
101
+ results = {}
102
+
103
+ for name, spec in engines.items():
104
+ engine_type = spec.get("type", "")
105
+ url = spec.get("url", "")
106
+ cmd = spec.get("cmd", [])
107
+
108
+ if engine_type == "http" and url:
109
+ ok, latency, err = _probe_http(url)
110
+ elif engine_type == "cli" and cmd:
111
+ ok, latency, err = _probe_cli(cmd)
112
+ else:
113
+ continue
114
+
115
+ # 更新数据库
116
+ with _connect() as conn:
117
+ row = conn.execute(
118
+ "SELECT consecutive_failures FROM engine_health WHERE engine = ?",
119
+ (name,),
120
+ ).fetchone()
121
+
122
+ if ok:
123
+ new_status = "available"
124
+ new_failures = 0
125
+ else:
126
+ prev_failures = row[0] if row else 0
127
+ new_failures = prev_failures + 1
128
+ new_status = "unavailable" if new_failures >= MAX_FAILURES else "degraded"
129
+
130
+ conn.execute(
131
+ """INSERT OR REPLACE INTO engine_health
132
+ (engine, status, consecutive_failures, last_probe_at, last_latency_ms, last_error)
133
+ VALUES (?, ?, ?, ?, ?, ?)""",
134
+ (name, new_status, new_failures, time.time(), latency, err),
135
+ )
136
+ conn.commit()
137
+
138
+ results[name] = {
139
+ "status": "available" if ok else "unavailable",
140
+ "latency_ms": round(latency, 1),
141
+ "error": err,
142
+ }
143
+
144
+ return results
145
+
146
+
147
+ def get_engine_status(engine: str) -> dict[str, Any]:
148
+ """查询单个引擎的健康状态。"""
149
+ _init_db()
150
+ with _connect() as conn:
151
+ row = conn.execute(
152
+ "SELECT status, consecutive_failures, last_probe_at, last_latency_ms FROM engine_health WHERE engine = ?",
153
+ (engine,),
154
+ ).fetchone()
155
+ if not row:
156
+ return {"status": "unknown", "consecutive_failures": 0, "available": True}
157
+ return {
158
+ "status": row[0],
159
+ "consecutive_failures": row[1],
160
+ "last_probe_at": row[2],
161
+ "last_latency_ms": row[3],
162
+ "available": row[0] != "unavailable",
163
+ }
164
+
165
+
166
+ def get_all_status() -> dict[str, dict[str, Any]]:
167
+ """获取所有引擎的健康状态。"""
168
+ _init_db()
169
+ with _connect() as conn:
170
+ rows = conn.execute(
171
+ "SELECT engine, status, consecutive_failures, last_probe_at, last_latency_ms FROM engine_health"
172
+ ).fetchall()
173
+ return {
174
+ row[0]: {
175
+ "status": row[1],
176
+ "consecutive_failures": row[2],
177
+ "last_probe_at": row[3],
178
+ "last_latency_ms": row[4],
179
+ "available": row[1] != "unavailable",
180
+ }
181
+ for row in rows
182
+ }
183
+
184
+
185
+ def watch_background():
186
+ """后台持续监控模式。在后台线程中运行,不阻塞 MCP 启动。"""
187
+ def _loop():
188
+ # 首次探测延迟 10 秒,让 MCP 先完成 initialize 握手
189
+ time.sleep(10)
190
+ try:
191
+ probe_all_engines()
192
+ except Exception:
193
+ pass
194
+ while True:
195
+ time.sleep(PROBE_INTERVAL)
196
+ try:
197
+ probe_all_engines()
198
+ except Exception:
199
+ pass
200
+
201
+ t = threading.Thread(target=_loop, daemon=True)
202
+ t.start()
203
+
204
+
205
+ # ── CLI ───────────────────────────────────────────────────────────────────────
206
+
207
+ if __name__ == "__main__":
208
+ if "--watch" in sys.argv:
209
+ print("启动后台健康监控(每 5 分钟)...", file=sys.stderr)
210
+ watch_background()
211
+ try:
212
+ while True:
213
+ time.sleep(60)
214
+ except KeyboardInterrupt:
215
+ pass
216
+ else:
217
+ results = probe_all_engines()
218
+ print(json.dumps(results, ensure_ascii=False, indent=2))