init: 商品图合规检测工具(豆包初筛 + DeepSeek 复检级联)
- src 标准布局:config/providers/pipeline/report + CLI/Tk GUI 双入口 - 级联省钱:豆包全量初筛,仅无违规/违规不明图进 DeepSeek 复检(含两票复核) - 输出:时间戳目录 + 分类文件夹图片归档 + Excel 报表 - 30 个单元测试(tests/,测试图片不入库)
This commit is contained in:
@@ -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 Key(DeepSeek 模型有默认值)
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user