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:
+70
-11
@@ -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"
|
||||
# 开关只影响 cascade;doubao/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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user