Files
violation-detector/tests/test_providers.py
3218485270 b413104587 feat: 结构化输出按官方规范 + 两段式重试 + DeepSeek 复检开关
- 豆包: response_format.json_schema(strict);DeepSeek: 切 Responses API text.format.json_schema
  (官方 chat/completions 通道不支持 json_schema),不支持时自动降级 json_object([output] schema)
- 重试两段式:传输错误 cfg.retries 次;解析失败/空正文有独立 3 次专用重试,仍失败落 parse_fail
- 移除三票复核(decide_final/_verify_clean/verify_clean),DeepSeek 单次判定即终稿
- 新增 [run] deepseek_recheck 开关(默认 no=仅豆包初筛;yes=追加 DeepSeek 复检)
- GUI 不再覆盖 config 提示词;prompt 相对/前导斜杠路径按 exe 目录解析
- 报表去「票型/复核」列并同步说明;README/config.example.ini 同步
- 测试新增输出格式、重试、开关与提示词路径用例(50 passed)
2026-09-03 11:44:10 +08:00

270 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""providers 模块测试:判定 JSON 解析、分类归一化、图片编码、usage 规整、output_schema、重试。"""
import asyncio
import base64
import pytest
from violation_detector.config import AppConfig
from violation_detector.providers import (
CANON, OUTPUT_SCHEMA, SCHEMA_CATEGORIES, SCHEMA_NAME, _chat_detect,
_extract_output_text, _norm_usage, _result_responses,
_schema_explicitly_unsupported, build_chat_payload,
build_responses_payload, encode_image, norm_category, parse_judgment,
)
GOOD = ('{"文字/图形属性": "黑色T恤印字母", "侵权/违规逻辑": "未授权使用商标", '
'"违规分类": "12. 侵权 - 除人物外的其他侵权"}')
def test_parse_plain_json():
r = parse_judgment(GOOD)
assert r is not None
assert r[0] == "黑色T恤印字母"
assert r[1] == "未授权使用商标"
assert r[2] == "12. 侵权 - 除人物外的其他侵权"
def test_parse_fenced_json():
r = parse_judgment("```json\n" + GOOD + "\n```")
assert r is not None and r[2].startswith("12.")
def test_parse_json_with_surrounding_text():
r = parse_judgment("分析如下:\n" + GOOD + "\n以上。")
assert r is not None
def test_parse_invalid():
assert parse_judgment("") is None
assert parse_judgment("这不是JSON") is None
assert parse_judgment('{"文字/图形属性": "x"}') is None # 缺字段
assert parse_judgment('{"a": [1,2]') is None # 截断
def test_norm_category_variants():
# 不同写法归一到标准 17 类名称
assert norm_category("12. 侵权 - 人物外") == CANON[12]
assert norm_category("14脏话、侮辱性") == CANON[14]
assert norm_category("5. 负向敏感") == CANON[5]
# 非编号类原样保留
assert norm_category("无违规") == "无违规"
assert norm_category("违规不明") == "违规不明"
# 未知编号保留原文
assert norm_category("99. 未来分类") == "99. 未来分类"
def test_encode_image_roundtrip(tmp_path):
img = tmp_path / "a.jpg"
img.write_bytes(b"\xff\xd8\xff\xe0fake")
url = encode_image(str(img))
assert url.startswith("data:image/jpeg;base64,")
assert base64.b64decode(url.split(",", 1)[1]) == b"\xff\xd8\xff\xe0fake"
def test_encode_image_unsupported(tmp_path):
p = tmp_path / "a.txt"
p.write_text("x", encoding="utf-8")
with pytest.raises(ValueError):
encode_image(str(p))
def test_norm_usage_handles_missing_details():
assert _norm_usage({"prompt_tokens": 100, "completion_tokens": 50}) == {
"prompt": 100, "completion": 50, "cached": 0}
assert _norm_usage({}) == {"prompt": None, "completion": None, "cached": 0}
assert _norm_usage({"prompt_tokens": 100, "prompt_tokens_details": {"cached_tokens": 80}})["cached"] == 80
# ---------- output_schema ----------
def test_output_schema_shape():
assert OUTPUT_SCHEMA["type"] == "object"
assert set(OUTPUT_SCHEMA["required"]) == {"文字/图形属性", "侵权/违规逻辑", "违规分类"}
assert OUTPUT_SCHEMA["additionalProperties"] is False
assert set(OUTPUT_SCHEMA["properties"]) == set(OUTPUT_SCHEMA["required"])
def test_schema_categories_covers_canon_and_specials():
expect = [CANON[n] for n in sorted(CANON)] + ["无违规", "违规不明"]
assert SCHEMA_CATEGORIES == expect
def test_build_chat_payload_schema_vs_json():
# 豆包:结构化输出 = response_format.json_schema(strict);降级 = json_object
messages = [{"role": "user", "content": []}]
s = build_chat_payload("m", messages, {"temperature": 0}, True)
assert s["model"] == "m" and s["temperature"] == 0
rf = s["response_format"]
assert rf["type"] == "json_schema"
assert rf["json_schema"]["name"] == SCHEMA_NAME
assert rf["json_schema"]["strict"] is True
assert rf["json_schema"]["schema"] is OUTPUT_SCHEMA
j = build_chat_payload("m", messages, {}, False)
assert j["response_format"] == {"type": "json_object"}
def test_build_responses_payload_schema():
# DeepSeek:结构化输出 = text.format.json_schemaResponses API
items = [{"role": "user", "content": [{"type": "input_text", "text": "p"}]}]
p = build_responses_payload("m", items, {"max_output_tokens": 5000}, True)
assert p["model"] == "m" and p["max_output_tokens"] == 5000
fmt = p["text"]["format"]
assert fmt["type"] == "json_schema"
assert fmt["name"] == SCHEMA_NAME
assert fmt["schema"] is OUTPUT_SCHEMA
def test_extract_output_text_concats_output_text():
data = {"output": [
{"type": "reasoning", "role": "assistant",
"content": [{"type": "reasoning_text", "text": "思考"}],
"status": "completed"},
{"type": "message", "role": "assistant",
"content": [{"type": "output_text", "text": '{"a": 1}'}],
"status": "completed"},
]}
assert _extract_output_text(data) == '{"a": 1}'
def test_result_responses_ok():
data = {
"status": "completed",
"usage": {"input_tokens": 10, "output_tokens": 20,
"input_tokens_details": {"cached_tokens": 4}},
"output": [{"type": "message", "role": "assistant", "content": [
{"type": "output_text", "text": '{"文字/图形属性": "T恤", '
'"侵权/违规逻辑": "无", "违规分类": "无违规"}'}]}],
}
r = _result_responses("a.jpg", data)
assert r["status"] == "ok" and r["category"] == "无违规"
assert r["usage"] == {"prompt": 10, "completion": 20, "cached": 4}
assert r["finish_reason"] == "completed"
def test_schema_explicitly_unsupported():
assert _schema_explicitly_unsupported(
"Unrecognized request argument supplied: response_format.json_schema")
assert _schema_explicitly_unsupported("model not support json_schema")
assert _schema_explicitly_unsupported("不支持的参数 json_schema")
assert not _schema_explicitly_unsupported("HTTP 400 some other problem")
# ---------- 重试机制(错误码 / 空正文 / 解析失败) ----------
RETRY_GOOD = '{"文字/图形属性": "T恤", "侵权/违规逻辑": "无", "违规分类": "无违规"}'
RETRY_BAD = '{"文字/图形属性": "T恤"}' # 缺字段,解析失败
def _chat_ok(content):
return {"choices": [{"message": {"content": content}, "finish_reason": "stop"}],
"usage": {}}
class _FakeResp:
def __init__(self, status, data=None, body=""):
self.status = status
self._data = data
self._body = body
async def json(self):
return self._data
async def text(self):
return self._body
class _FakeCM:
def __init__(self, resp):
self._resp = resp
async def __aenter__(self):
return self._resp
async def __aexit__(self, *exc):
return False
class _FakeSession:
def __init__(self, responses):
self.responses = list(responses)
self.posts = 0
def post(self, url, json, headers):
resp = self.responses[min(self.posts, len(self.responses) - 1)]
self.posts += 1
return _FakeCM(resp)
def _run_chat(session, retries=3):
cfg = AppConfig(output_schema_mode="off", retries=retries)
return asyncio.run(_chat_detect(
session, asyncio.Semaphore(1), cfg, "a.jpg", stop=lambda: False,
provider="deepseek", label="DeepSeek",
base_url="https://fake.deepseek.com", api_key="k", model="m",
messages=[{"role": "user", "content": "p"}],
extras={"temperature": 0, "max_tokens": 50}))
async def _no_sleep(_):
return None
def test_parse_fail_retries_then_recovers(monkeypatch):
# 第 1 次返回不可解析 → 触发重试;第 2 次成功
monkeypatch.setattr(asyncio, "sleep", _no_sleep)
sess = _FakeSession([_FakeResp(200, _chat_ok(RETRY_BAD)),
_FakeResp(200, _chat_ok(RETRY_GOOD))])
res = _run_chat(sess)
assert res["status"] == "ok" and res["category"] == "无违规"
assert sess.posts == 2
def test_parse_fail_exhausts_to_parse_fail(monkeypatch):
# 一直解析失败 → 第 1 次进入解析段,独立再重试 3 次后落 parse_fail(违规不明)
monkeypatch.setattr(asyncio, "sleep", _no_sleep)
sess = _FakeSession([_FakeResp(200, _chat_ok(RETRY_BAD))])
res = _run_chat(sess, retries=3)
assert res["status"] == "parse_fail" and res["category"] == "违规不明"
assert sess.posts == 1 + 3 # 首判 + 3 次独立解析重试
def test_parse_fail_dedicated_budget_ignores_http_errors(monkeypatch):
# 先 1 次 HTTP 500,再解析失败:HTTP 错误不消耗解析段次数,
# 解析仍独立获得 cfg.retries 次专用重试
monkeypatch.setattr(asyncio, "sleep", _no_sleep)
sess = _FakeSession([_FakeResp(500, body="boom"),
_FakeResp(200, _chat_ok(RETRY_BAD))])
res = _run_chat(sess, retries=3)
assert res["status"] == "parse_fail" and res["category"] == "违规不明"
assert sess.posts == 2 + 3 # 1×HTTP500 + 首判解析失败 + 3×解析专用重试
def test_error_code_retries_then_recovers(monkeypatch):
# HTTP 500 → 退避重试;随后 200 成功
monkeypatch.setattr(asyncio, "sleep", _no_sleep)
sess = _FakeSession([_FakeResp(500, body="boom"),
_FakeResp(200, _chat_ok(RETRY_GOOD))])
res = _run_chat(sess)
assert res["status"] == "ok" and res["category"] == "无违规"
assert sess.posts == 2
def test_error_code_exhausts_to_error(monkeypatch):
# 一直 HTTP 500 → 重试耗尽落 error(检测异常)
monkeypatch.setattr(asyncio, "sleep", _no_sleep)
sess = _FakeSession([_FakeResp(500, body="boom")])
res = _run_chat(sess, retries=3)
assert res["status"] == "error" and res["category"] == "检测异常"
assert sess.posts == 3
def test_empty_content_retries(monkeypatch):
# 空正文 → 重试;随后成功
monkeypatch.setattr(asyncio, "sleep", _no_sleep)
sess = _FakeSession([_FakeResp(200, _chat_ok("")),
_FakeResp(200, _chat_ok(RETRY_GOOD))])
res = _run_chat(sess)
assert res["status"] == "ok" and res["category"] == "无违规"
assert sess.posts == 2