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:
+202
-2
@@ -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_schema(Responses 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
|
||||
|
||||
Reference in New Issue
Block a user