init: 商品图合规检测工具(豆包初筛 + DeepSeek 复检级联)

- src 标准布局:config/providers/pipeline/report + CLI/Tk GUI 双入口
- 级联省钱:豆包全量初筛,仅无违规/违规不明图进 DeepSeek 复检(含两票复核)
- 输出:时间戳目录 + 分类文件夹图片归档 + Excel 报表
- 30 个单元测试(tests/,测试图片不入库)
This commit is contained in:
yeuimu
2026-09-02 15:28:51 +08:00
commit 08d26710b4
20 changed files with 2513 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
"""config 模块测试:模板生成、缺项检测、读写回环、提示词路径。"""
from violation_detector.config import (
AppConfig, ensure_config, load_config, missing_fields, save_config,
)
def test_ensure_config_creates_template(tmp_path):
ini = tmp_path / "config.ini"
path = ensure_config(str(ini))
assert path == ini
text = ini.read_text(encoding="utf-8")
assert "[ark]" in text and "[deepseek]" in text
def test_ensure_config_keeps_existing(tmp_path):
ini = tmp_path / "config.ini"
ini.write_text("[ark]\napi_key = abc\n", encoding="utf-8")
ensure_config(str(ini))
assert "api_key = abc" in ini.read_text(encoding="utf-8")
def test_missing_fields_all_empty():
# deepseek_model 有默认值;只有用户在 ini 里清空才视为缺失
missing = missing_fields(AppConfig(deepseek_model=""))
assert len(missing) == 4
assert any("豆包 API Key" in m for m in missing)
assert any("DeepSeek API Key" in m for m in missing)
def test_missing_fields_default_model_ok():
# 默认仅缺:豆包 Key、豆包接入点、DeepSeek KeyDeepSeek 模型有默认值)
missing = missing_fields(AppConfig())
assert len(missing) == 3
assert not any("DeepSeek 模型" in m for m in missing)
def test_missing_fields_none_when_filled():
cfg = AppConfig(ark_api_key="k1", ark_model="ep-1",
deepseek_api_key="sk-1", deepseek_model="m1")
assert missing_fields(cfg) == []
def test_save_and_load_roundtrip(tmp_path):
ini = tmp_path / "config.ini"
cfg = AppConfig(ark_api_key="ark-key", ark_model="ep-xyz",
deepseek_api_key="sk-key", deepseek_model="ds-model",
ark_workers=8, max_tokens=4000, prompt_file="my.txt")
save_config(cfg, str(ini))
loaded = load_config(str(ini))
assert loaded.ark_api_key == "ark-key"
assert loaded.ark_model == "ep-xyz"
assert loaded.deepseek_api_key == "sk-key"
assert loaded.deepseek_model == "ds-model"
assert loaded.ark_workers == 8
assert loaded.max_tokens == 4000
assert loaded.prompt_file == "my.txt"
assert loaded.recheck_categories == ["无违规", "违规不明", "检测异常"]
def test_prompt_path_defaults_to_builtin():
cfg = AppConfig()
assert cfg.prompt_path.name == "prompts.txt"
def test_prompt_path_custom(tmp_path):
p = tmp_path / "p.txt"
p.write_text("x", encoding="utf-8")
cfg = AppConfig(prompt_file=str(p))
assert cfg.prompt_path == p
+78
View File
@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
"""pipeline 模块测试:排序、投票裁决、成本计算、缓存路径。"""
from violation_detector.pipeline import (
_cache_path, _cat_num, deepseek_cost, decide_final, natural_key,
)
def R(cat):
return {"category": cat, "attr": "a", "logic": "l", "file": "x.jpg",
"usage": {"prompt": 2199, "completion": 2282, "cached": 1792}}
def test_natural_key_sort():
names = ["10_a.jpg", "2_a.jpg", "1_a.jpg", "21_a.jpg", "3_a.jpg"]
assert sorted(names, key=natural_key) == ["1_a.jpg", "2_a.jpg", "3_a.jpg",
"10_a.jpg", "21_a.jpg"]
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("无违规")}
peak, idle, comp = deepseek_cost(results)
expect_peak = (407 * 3.0 + 1792 * 0.10 + 2282 * 9.0) / 1e6
expect_idle = (407 * 1.5 + 1792 * 0.05 + 2282 * 4.5) / 1e6
assert abs(peak - expect_peak) < 1e-9
assert abs(idle - expect_idle) < 1e-9
assert comp == 2282
def test_deepseek_cost_empty():
assert deepseek_cost({}) == (0.0, 0.0, 0.0)
def test_cache_path_sanitizes_folder(tmp_path):
p = _cache_path("ark", str(tmp_path / "we ird\\name+:x"))
assert p.parent.exists()
assert p.name.startswith("ark_")
for ch in ' :+':
assert ch not in p.name
+69
View File
@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
"""providers 模块测试:判定 JSON 解析、分类归一化、图片编码、usage 规整。"""
import base64
import pytest
from violation_detector.providers import CANON, _norm_usage, 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
+73
View File
@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
"""report 模块测试:目录名清洗、输出组织(时间戳+分类归档)、Excel 报表。"""
from openpyxl import load_workbook
from violation_detector.report import build_report, organize_output, sanitize_dirname
def ROW(cat, fname="img.jpg", channel="豆包初筛"):
return {"file": fname, "category": cat, "channel": channel, "attr": "属性",
"logic": "逻辑", "status": "ok", "vote_note": "", "remark": ""}
def test_sanitize_dirname():
assert sanitize_dirname("12. 侵权 - 其他") == "12. 侵权 - 其他"
bad = 'a<b>c:"d/e\\f|g?h*i'
clean = sanitize_dirname(bad)
for ch in '<>:"/\\|?*':
assert ch not in clean
assert clean == "a_b_c__d_e_f_g_h_i"
assert sanitize_dirname(" . ") == "未分类"
assert len(sanitize_dirname("" * 100)) <= 80
def test_organize_output_copies_images_by_category(tmp_path):
src = tmp_path / "src"
src.mkdir()
for name in ("a.jpg", "b.jpg", "c.jpg"):
(src / name).write_bytes(b"img")
rows = [ROW("12. 侵权 - 除人物外的其他侵权", "a.jpg"),
ROW("无违规", "b.jpg"),
ROW("12. 侵权 - 除人物外的其他侵权", "c.jpg")]
ts_dir = organize_output(rows, str(src), str(tmp_path / "out"))
assert ts_dir.name.startswith("检测结果_")
assert (ts_dir / "12. 侵权 - 除人物外的其他侵权" / "a.jpg").read_bytes() == b"img"
assert (ts_dir / "12. 侵权 - 除人物外的其他侵权" / "c.jpg").exists()
assert (ts_dir / "无违规" / "b.jpg").exists()
# 源文件保留(复制而非移动)
assert (src / "a.jpg").exists()
def test_organize_output_missing_image_no_crash(tmp_path):
rows = [ROW("无违规", "不存在.jpg")]
ts_dir = organize_output(rows, str(tmp_path), str(tmp_path / "out"))
assert (ts_dir / "无违规").is_dir()
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")
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)]
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)]
assert cats[0].startswith("12.") and cats[1] == "无违规"
assert ws2.cell(row=5, column=3).value == 1
assert ws2.cell(row=6, column=3).value == 2
# 合计行
assert ws2.cell(row=7, column=3).value == 3