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
+70 -11
View File
@@ -1,7 +1,9 @@
# -*- coding: utf-8 -*-
"""config 模块测试:模板生成、缺项检测、读写回环、提示词路径。"""
import violation_detector.config as config_mod
from violation_detector.config import (
AppConfig, ensure_config, load_config, missing_fields, save_config,
AppConfig, effective_mode, ensure_config, load_config, missing_fields,
save_config,
)
@@ -63,18 +65,57 @@ def test_mode_from_ini(tmp_path):
assert load_config(str(ini)).mode == "cascade"
def test_verify_clean_from_ini(tmp_path):
def test_output_schema_from_ini(tmp_path):
ini = tmp_path / "config.ini"
ini.write_text("[run]\nverify_clean = no\n", encoding="utf-8")
assert load_config(str(ini)).verify_clean is False
ini.write_text("[run]\nverify_clean = yes\n", encoding="utf-8")
assert load_config(str(ini)).verify_clean is True
# 非法值保持默认开启
ini.write_text("[run]\nverify_clean = 也许\n", encoding="utf-8")
assert load_config(str(ini)).verify_clean is True
# 未配置保持默认开启
ini.write_text("[output]\nschema = on\n", encoding="utf-8")
assert load_config(str(ini)).output_schema_mode == "on"
ini.write_text("[output]\nschema = off\n", encoding="utf-8")
assert load_config(str(ini)).output_schema_mode == "off"
ini.write_text("[output]\nschema = auto\n", encoding="utf-8")
assert load_config(str(ini)).output_schema_mode == "auto"
# 非法值回退 auto;未配置默认 auto
ini.write_text("[output]\nschema = 乱来\n", encoding="utf-8")
assert load_config(str(ini)).output_schema_mode == "auto"
ini.write_text("[ark]\n", encoding="utf-8")
assert load_config(str(ini)).verify_clean is True
assert load_config(str(ini)).output_schema_mode == "auto"
def test_output_schema_roundtrip(tmp_path):
ini = tmp_path / "config.ini"
save_config(AppConfig(output_schema_mode="off"), str(ini))
assert load_config(str(ini)).output_schema_mode == "off"
assert AppConfig().output_schema_mode == "auto"
def test_deepseek_recheck_default_and_ini(tmp_path):
# 默认关
assert AppConfig().deepseek_recheck is False
ini = tmp_path / "config.ini"
ini.write_text("[run]\ndeepseek_recheck = yes\n", encoding="utf-8")
assert load_config(str(ini)).deepseek_recheck is True
ini.write_text("[run]\ndeepseek_recheck = no\n", encoding="utf-8")
assert load_config(str(ini)).deepseek_recheck is False
# 非法值/未配置保持默认关
ini.write_text("[run]\ndeepseek_recheck = 也许\n", encoding="utf-8")
assert load_config(str(ini)).deepseek_recheck is False
ini.write_text("[ark]\n", encoding="utf-8")
assert load_config(str(ini)).deepseek_recheck is False
def test_effective_mode_respects_recheck_switch():
on = AppConfig(mode="cascade", deepseek_recheck=True)
off = AppConfig(mode="cascade", deepseek_recheck=False)
assert effective_mode(on, "cascade") == "cascade"
assert effective_mode(off, "cascade") == "doubao"
# 开关只影响 cascadedoubao/deepseek 不受影响
assert effective_mode(off, "doubao") == "doubao"
assert effective_mode(off, "deepseek") == "deepseek"
def test_deepseek_recheck_roundtrip(tmp_path):
ini = tmp_path / "config.ini"
save_config(AppConfig(deepseek_recheck=True), str(ini))
assert load_config(str(ini)).deepseek_recheck is True
def test_save_and_load_roundtrip(tmp_path):
@@ -104,3 +145,21 @@ def test_prompt_path_custom(tmp_path):
p.write_text("x", encoding="utf-8")
cfg = AppConfig(prompt_file=str(p))
assert cfg.prompt_path == p
def test_prompt_path_relative_prefers_app_dir(tmp_path, monkeypatch):
(tmp_path / "prompts.txt").write_text("x", encoding="utf-8")
monkeypatch.setattr(config_mod, "app_dir", lambda: tmp_path)
monkeypatch.setattr(config_mod, "resource_dir", lambda: tmp_path)
cfg = AppConfig(prompt_file="prompts.txt")
assert cfg.prompt_path == tmp_path / "prompts.txt"
def test_prompt_path_leading_slash_treated_relative(tmp_path, monkeypatch):
# 无盘符的 /prompts.txt 应视为 exe 同目录的相对文件(Windows 容错)
(tmp_path / "prompts.txt").write_text("x", encoding="utf-8")
monkeypatch.setattr(config_mod, "app_dir", lambda: tmp_path)
monkeypatch.setattr(config_mod, "resource_dir", lambda: tmp_path)
for raw in ("/prompts.txt", "\\prompts.txt"):
cfg = AppConfig(prompt_file=raw)
assert cfg.prompt_path == tmp_path / "prompts.txt", raw
+3 -34
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""pipeline 模块测试:排序、投票裁决、成本计算、缓存路径。"""
"""pipeline 模块测试:排序、成本计算、缓存路径。"""
from violation_detector.pipeline import (
_cache_path, _cat_num, deepseek_cost, decide_final, natural_key,
_cache_path, _cat_num, deepseek_cost, natural_key,
)
@@ -19,42 +19,11 @@ def test_natural_key_sort():
def test_cat_num():
assert _cat_num("12. 侵权 - 其他") == 12
assert _cat_num("1. 烟草") == 1
# 无编号的结论统一排到最后(无违规/违规不变动分类,不影响裁决
# 无编号的结论统一排到最后(无违规/违规不明不变动分类)
assert _cat_num("无违规") == 99
assert _cat_num("违规不明") == 99
def test_decide_final_unanimous_clean():
final, note = decide_final([R("无违规"), R("无违规"), R("无违规")])
assert final["category"] == "无违规"
assert "3票一致" in note
def test_decide_final_clean_majority_keeps_but_flags():
final, note = decide_final([R("无违规"), R("无违规"), R("12. 侵权 - 除人物外的其他侵权")])
assert final["category"] == "无违规"
assert "2:1" in note and "人工复核" in note
def test_decide_final_violation_majority_wins():
final, note = decide_final(
[R("无违规"), R("12. 侵权 - 除人物外的其他侵权"), R("12. 侵权 - 除人物外的其他侵权")])
assert final["category"].startswith("12.")
assert "人工复核" in note
def test_decide_final_three_way_split_takes_lowest_number():
final, note = decide_final([R("无违规"), R("13. 侵权 - 人物相关"), R("5. 负向敏感")])
assert final["category"].startswith("5.")
assert "从严" in note
def test_decide_final_two_violations_differ_majority_none():
# 两张违规票不同、一张无违规:违规方 2 票 > 1,并列取序号最小
final, _ = decide_final([R("无违规"), R("13. 侵权 - 人物相关"), R("12. 侵权 - 其他")])
assert final["category"].startswith("12.")
def test_deepseek_cost_known_usage():
# miss 407 + cached 1792 + completion 2282
results = {"a": R("无违规")}
+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
+3 -5
View File
@@ -7,7 +7,7 @@ from violation_detector.report import build_report, organize_output, sanitize_di
def ROW(cat, fname="img.jpg", channel="豆包初筛"):
return {"file": fname, "category": cat, "channel": channel, "attr": "属性",
"logic": "逻辑", "status": "ok", "vote_note": "", "remark": ""}
"logic": "逻辑", "status": "ok", "remark": ""}
def test_sanitize_dirname():
@@ -48,7 +48,6 @@ def test_build_report_structure(tmp_path):
rows = [ROW("12. 侵权 - 除人物外的其他侵权", "a.jpg"),
ROW("无违规", "b.jpg", channel="DeepSeek复检"),
ROW("无违规", "b.jpg")]
rows[2]["vote_note"] = "票型2:1(无违规 / 无违规 / 12. 侵权),建议人工复核"
out = build_report(rows, str(tmp_path), {"mode": "cascade", "ds_calls": 3,
"ds_peak_cost": 0.1, "ds_idle_cost": 0.05})
assert out.endswith(".xlsx")
@@ -56,13 +55,12 @@ def test_build_report_structure(tmp_path):
wb = load_workbook(out)
ws = wb["检测结果"]
assert ws["B2"].value.startswith("商品图合规检测工具")
headers = [ws.cell(row=4, column=c).value for c in range(2, 10)]
headers = [ws.cell(row=4, column=c).value for c in range(2, 9)]
assert headers == ["序号", "文件名", "违规分类", "判定通道", "文字/图形属性",
"侵权/违规逻辑", "票型/复核", "备注"]
"侵权/违规逻辑", "备注"]
assert ws.cell(row=5, column=2).value == 1
assert ws.cell(row=6, column=5).value == "DeepSeek复检"
assert ws.cell(row=5, column=5).value == "豆包初筛"
assert "人工复核" in ws.cell(row=7, column=8).value
ws2 = wb["统计汇总"]
cats = [ws2.cell(row=r, column=2).value for r in (5, 6)]