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)
This commit is contained in:
2026-09-03 11:44:10 +08:00
parent ae3a4e06f1
commit b413104587
12 changed files with 832 additions and 289 deletions
+202 -2
View File
@@ -1,10 +1,17 @@
# -*- coding: utf-8 -*-
"""providers 模块测试:判定 JSON 解析、分类归一化、图片编码、usage 规整。"""
"""providers 模块测试:判定 JSON 解析、分类归一化、图片编码、usage 规整、output_schema、重试"""
import asyncio
import base64
import pytest
from violation_detector.providers import CANON, _norm_usage, encode_image, norm_category, parse_judgment
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. 侵权 - 除人物外的其他侵权"}')
@@ -67,3 +74,196 @@ def test_norm_usage_handles_missing_details():
"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