feat(v1.8.0): 稳定性修复 + AI Agent 体验增强

稳定性修复:

- 修复 cache.py SQLite 连接泄漏(contextlib.closing 包装)

- 修复 fetch.py requests stream=True 连接泄漏(try/finally resp.close())

- RETRYABLE_STATUS 新增 403,激活 UA fallback 切换逻辑

- --cache-stats 移至实例解析前,无需实例即可查询

- classify_error 从错误消息提取 HTTP 状态码,正确分类 E_AUTH/E_RATE_LIMIT

- --stream 与 --queries-file 互斥检查,违规报 E_INPUT

- batch 退出码语义统一(0=有结果 / 1=全部错误 / 2=全部空结果)

AI Agent 体验增强:

- 错误码体系完善:E_CONFIG/E_AUTH/E_NETWORK/E_RATE_LIMIT/E_PARSE/E_EMPTY/E_INPUT/E_INTERNAL

- recovery_hint 恢复提示字段,AI Agent 可程序化决策恢复策略

- stream 模式新增 error 事件类型(含 error_code + recovery_hint)

- 进度事件扩展:instance_try/instance_ok/instance_fail

- batch 模式统一 schema(status 字段区分 success/failed)

- JSON 输出含 schema_version 字段确保版本兼容

测试与文档:

- 测试覆盖:330 -> 352

- SKILL.md / README.md 同步更新
This commit is contained in:
2026-08-01 19:02:44 +08:00
parent dea899143d
commit fb9b2af45f
12 changed files with 871 additions and 131 deletions
+116 -2
View File
@@ -23,7 +23,7 @@ from types import SimpleNamespace
import pytest
from search import _run_single_query, _emit_error, _build_params
from search import _run_single_query, _emit_error, _build_params, _format_results
import cache as cache_module
PROJECT_ROOT = Path(__file__).resolve().parent.parent
@@ -256,7 +256,7 @@ def test_cli_version_prints_version():
"""`--version` exits 0 and prints the version string."""
r = _run_cli("--version")
assert r.returncode == 0
assert "1.7.0" in r.stdout
assert "1.8.0" in r.stdout
assert "searxng-cli" in r.stdout
@@ -294,3 +294,117 @@ def test_cli_cache_stats_outputs_json(tmp_path):
data = json.loads(r.stdout)
assert "entries" in data
assert "path" in data
# ===== --dump-schema (v1.8.0) =====
def test_cli_dump_schema_outputs_valid_json():
"""`--dump-schema` prints valid JSON with schema_version/title/properties.
The schema dump is a non-search operation: it exits 0 and does NOT
require --query or -i (it short-circuits before instance resolution).
"""
r = _run_cli("--dump-schema")
assert r.returncode == 0
data = json.loads(r.stdout)
assert data["schema_version"] == "1.0"
assert data["title"] == "SearXNG CLI Search Result"
assert "properties" in data
assert isinstance(data["properties"], dict)
def test_cli_dump_schema_does_not_require_query():
"""`--dump-schema` exits 0 without --query (non-search operation)."""
r = _run_cli("--dump-schema")
assert r.returncode == 0
# The --query-required check must not fire for --dump-schema.
assert "required" not in r.stderr.lower()
# ===== --stream mutual exclusion (v1.8.0) =====
def test_cli_stream_with_queries_file_rejected(tmp_path):
"""--stream + --queries-file is rejected with E_INPUT on stdout.
Batch mode emits a JSON array, not JSON Lines; the combination is
explicitly rejected so AI agents don't silently get the wrong format.
"""
qf = tmp_path / "queries.txt"
qf.write_text("query\n", encoding="utf-8")
r = _run_cli("--stream", "--queries-file", str(qf),
"--format", "json", "-i", "https://x.example.com")
assert r.returncode == 1
data = json.loads(r.stdout)
assert data["error_code"] == "E_INPUT"
def test_cli_stream_with_csv_format_rejected():
"""--stream + --format csv is rejected with E_INPUT.
csv is a non-json format, so _emit_error routes the error to stderr
(as a ``[E_INPUT]`` prefixed log line) rather than stdout JSON.
"""
r = _run_cli("--stream", "--format", "csv", "-q", "test",
"-i", "https://x.example.com")
assert r.returncode == 1
assert "E_INPUT" in r.stderr
# ===== schema_version in single-query JSON (v1.8.0) =====
def test_format_results_json_includes_schema_version():
"""Single-query JSON output includes schema_version: '1.0'."""
results = {"results": [{"title": "T", "url": "https://example.com"}]}
args = SimpleNamespace(format="json")
out = _format_results(results, args)
parsed = json.loads(out)
assert parsed["schema_version"] == "1.0"
# ===== _fallback field must not leak to JSON output (v1.8.0) =====
def test_run_single_query_html_fallback_pops_underscore_fallback(monkeypatch):
"""HTML-fallback's internal _fallback field is removed before output.
_run_single_query pops _fallback at the end so it never appears in
the JSON payload; fetched_source (the public field) is set separately
on the --fetch path.
"""
args = _make_args(format="json")
fake = {"results": [{"url": "https://a.com/1", "title": "A"}],
"_fallback": "html"}
monkeypatch.setattr("search.search_multi", lambda *a, **kw: fake)
results, err, _ = _run_single_query("test", args,
["https://x.example.com"], {}, 0)
assert err is None
# _fallback must be popped so it doesn't leak into JSON output
assert "_fallback" not in results
# And the serialized JSON also omits it
out = _format_results(results, args)
parsed = json.loads(out)
assert "_fallback" not in parsed
def test_run_single_query_fetch_sets_fetched_source_from_html_fallback(monkeypatch):
"""--fetch path sets fetched_source from _fallback (or 'json').
When the search came via HTML fallback (_fallback='html'), the
fetched_source field reflects that origin, while _fallback itself is
cleaned up and never reaches JSON output.
"""
args = _make_args(fetch=1)
fake = {"results": [{"url": "https://a.com/1"}], "_fallback": "html"}
monkeypatch.setattr("search.search_multi", lambda *a, **kw: fake)
monkeypatch.setattr("search.fetch_top_results", lambda *a, **kw: [
{"url": "https://a.com/1", "status": "ok", "text": "page A",
"text_length": 6, "truncated": False},
])
results, err, _ = _run_single_query("test", args,
["https://x.example.com"], {}, 0)
assert err is None
# fetched_source reflects the HTML fallback origin
assert results["fetched_source"] == "html"
# _fallback still cleaned up
assert "_fallback" not in results