OCSxAI/tests/test_ocs5_adversarial_e2e.py

575 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
OCS 5.0「图片题优化」对抗性 E2E 测试(会真实调用 DeepSeek,约 6 次,费用很小)
覆盖 Lead 要求的 6 组对抗场景:
a) markers 数量 > images 数量时的降级([图片3] 无处可放)
b) images 里有非字符串 / 空值 / 非法项
c) suggestion_title/options 缺失但 images 有值
d) 大 payload(2 张约 1MB base64 图片 + 1 张题干图)能正常答题,且日志不爆炸
e) 纯文字题:images=[]、suggestion_* 为空字符串
f) 旧版 payload(无 images 字段):仍能走 URL 下载视觉链路答题
g) 额外(真实 OCS 细节):GM 回退路径会把图片原始字节的 mime 前缀统一改写成
data:image/png;base64,...(例如 JPEG 字节+PNG 前缀),验证 DeepSeek 仍能识别。
除真实接口断言外,每组都在本地先用 ai_client 的纯函数做「消息构建预检」(不花钱),
并全程捕获 "ocs_ai" 日志,检查 base64 不会进入日志(日志脱敏)。
运行(项目根目录):
$env:PYTHONIOENCODING='utf-8'
py tests/test_ocs5_adversarial_e2e.py # 离线预检 + 真实 E2E(约 6 次 API)
py tests/test_ocs5_adversarial_e2e.py --offline-only # 只做离线预检,不联网不花钱
"""
import base64
import io
import logging
import os
import random
import re
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
os.chdir(ROOT)
OFFLINE_ONLY = "--offline-only" in sys.argv
FAILS = []
RISKS = []
def record(name: str, ok: bool, detail: str = "") -> bool:
print((" [PASS] " if ok else " [FAIL] ") + name + (f" | {detail}" if detail else ""))
if not ok:
FAILS.append(name)
return ok
def risk(name: str, detail: str = "") -> None:
print(f" [RISK] {name}" + (f" | {detail}" if detail else ""))
RISKS.append(f"{name} | {detail}")
def info(msg: str) -> None:
print(f" [INFO] {msg}")
# ────────────────────────── 图片生成 ──────────────────────────
FONT_CANDIDATES = [
r"C:\Windows\Fonts\arial.ttf",
r"C:\Windows\Fonts\arialbd.ttf",
r"C:\Windows\Fonts\segoeui.ttf",
]
def _load_font(size: int):
from PIL import ImageFont
for path in FONT_CANDIDATES:
if os.path.isfile(path):
try:
return ImageFont.truetype(path, size)
except Exception:
continue
try:
return ImageFont.load_default(size=size)
except TypeError:
return ImageFont.load_default()
def make_png(text: str, width: int = 700, height: int = 130, font_size: int = 46) -> bytes:
from PIL import Image, ImageDraw
img = Image.new("RGB", (width, height), "white")
draw = ImageDraw.Draw(img)
draw.text((30, height // 2 - font_size // 2 - 6), text, fill="black", font=_load_font(font_size))
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def make_jpg(text: str, width: int = 700, height: int = 130, font_size: int = 44) -> bytes:
from PIL import Image, ImageDraw
img = Image.new("RGB", (width, height), "white")
draw = ImageDraw.Draw(img)
draw.text((30, height // 2 - font_size // 2 - 6), text, fill="black", font=_load_font(font_size))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=90)
return buf.getvalue()
def to_data_url(png: bytes) -> str:
return "data:image/png;base64," + base64.b64encode(png).decode("ascii")
def make_noise_png(size: int = 512, seed: int = 20261102) -> bytes:
"""生成随机噪声 PNG(用来撑大 payload;内容是噪声,不期望模型读出含义)"""
from PIL import Image
rnd = random.Random(seed)
raw = bytes(rnd.getrandbits(8) for _ in range(size * size * 3))
img = Image.frombytes("RGB", (size, size), raw)
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
# ────────────────────────── 答案归一化 ──────────────────────────
def normalize_answer(raw) -> str:
if raw is None:
return ""
text = str(raw).strip().upper()
if not text:
return ""
letters = re.findall(r"(?<![A-Z])([A-H])(?![A-Z])", text)
if letters:
uniq = list(dict.fromkeys(letters))
return "#".join(uniq) if len(uniq) > 1 else uniq[0]
return re.sub(r"[^A-Z]", "", text)
# ────────────────────────── 日志捕获 ──────────────────────────
class LogCapture(logging.Handler):
def __init__(self):
super().__init__(level=logging.DEBUG)
self.messages = []
def emit(self, record):
try:
self.messages.append(record.getMessage())
except Exception:
self.messages.append("<log error>")
def reset(self):
self.messages = []
CAPTURE = LogCapture()
def check_logs(stage: str, *, expect_images: int = None) -> bool:
"""检查日志脱敏:不含 base64 内容、没有爆炸长行。"""
messages = list(CAPTURE.messages)
joined = "\n".join(messages)
max_line = max((len(m) for m in messages), default=0)
ok = True
if "iVBORw0KGgo" in joined:
record(f"{stage}:日志不包含图片 base64", False, "发现 PNG base64 签名")
ok = False
if "data:image/png;base64," in joined:
record(f"{stage}:日志不包含 data URL", False, "发现 data:image/png;base64,")
ok = False
if max_line >= 2000:
record(f"{stage}:日志单行长度 < 2000", False, f"max={max_line}")
ok = False
body_logs = [m for m in messages if "POST 请求体" in m]
if expect_images is not None:
want = f"<{expect_images} 张 base64 图片>"
if not any(want in m for m in body_logs):
record(f"{stage}:日志中 images 显示为占位文案", False, f"未找到 {want!r}")
ok = False
if ok:
record(f"{stage}:日志脱敏正常(max_line={max_line})", True)
return ok
# ────────────────────────── 离线预检 ──────────────────────────
def offline_checks() -> dict:
"""不花钱的消息构建预检,返回后续 E2E 需要的图片素材。"""
print("\n[离线预检] ai_client 消息构建 / 容错(不调 DeepSeek)")
from ai_client import (
QuestionInfo,
_iter_marker_segments,
_ocs_vision_messages,
_prepare_ocs_images,
)
q_png = make_png("Solve for x: 3x + 5 = 20")
q_data_url = to_data_url(q_png)
digit_pngs = {d: make_png(d, 220, 150, 64) for d in ("3", "5", "7", "9")}
# (a) markers 数量 > images 数量
qa = QuestionInfo(
title="原始标题(含坏 URL) https://p.ananas.chaoxing.com/not-found.png",
type="single",
options="A. 3\nB. 5\nC. 7\nD. 9",
images=[q_data_url],
suggestion_title="请解方程: [图片1] 另外 [图片3] ",
suggestion_options="",
)
prepared_a = _prepare_ocs_images(qa.images)
msgs_a = _ocs_vision_messages(qa, prepared_a)
blocks_a = msgs_a[1]["content"]
img_blocks_a = [b for b in blocks_a if b.get("type") == "image_url"]
text_a = " ".join(b.get("text", "") for b in blocks_a if b.get("type") == "text")
record("a) markers>images:有效图仍被正确放入消息", len(img_blocks_a) == 1,
f"image_blocks={len(img_blocks_a)}")
record("a) markers>images:越界标记 [图片3] 退化为文本(不崩溃)",
"[图片3]" in text_a and "[图片1]" not in text_a, text_a[:80])
record("a) markers>images:越界标记切分结果",
[(k, v) for k, v in _iter_marker_segments("x [图片3] y") if k == "image"] == [("image", 3)])
# (b) images 含非字符串 / 空值
raw_images_b = [None, "", "undefined", 123, {"oops": 1}, q_data_url]
filtered_b = _prepare_ocs_images(raw_images_b)
record("b) images 含非字符串/空值:过滤后只留有效图", filtered_b == [q_data_url],
f"len={len(filtered_b)}")
qb = QuestionInfo(
title="原始标题",
type="single",
options="A. 3\nB. 5\nC. 7\nD. 9",
images=raw_images_b,
suggestion_title="请解方程: [图片1] ",
suggestion_options="",
)
msgs_b = _ocs_vision_messages(qb, filtered_b)
record("b) 过滤后 [图片1] 仍能映射到有效图",
[b for b in msgs_b[1]["content"] if b.get("type") == "image_url"] != [])
# (c) suggestion_* 缺失但 images 有值
# R1 加固后:图片不再静默丢弃,会以「题目图片:」兜底附到消息里
qc = QuestionInfo(
title="Solve for x: 3x + 5 = 20",
type="single",
options="A. 3\nB. 5\nC. 7\nD. 9",
images=[q_data_url],
suggestion_title="",
suggestion_options="",
)
msgs_c = _ocs_vision_messages(qc, _prepare_ocs_images(qc.images))
img_blocks_c = [b for b in msgs_c[1]["content"] if b.get("type") == "image_url"]
text_c = " ".join(b.get("text", "") for b in msgs_c[1]["content"] if b.get("type") == "text")
record(
"c) suggestion_* 缺失:不崩溃,图片走兜底附到消息中",
len(img_blocks_c) == 1 and "题目图片" in text_c,
f"image_blocks={len(img_blocks_c)}",
)
# c-风险变体:title 是裸 URL、suggestion_* 缺失、只收到 images
# 这种 payload 只会出现在「题库配置只引用了 ${images} 而没引用 suggestion_*」的错误配置下。
qc_risk = QuestionInfo(
title="https://p.ananas.chaoxing.com/star3/origin/q.png",
type="single",
options="A. 3\nB. 5\nC. 7\nD. 9",
images=[q_data_url],
suggestion_title="",
suggestion_options="",
)
msgs_risk = _ocs_vision_messages(qc_risk, _prepare_ocs_images(qc_risk.images))
blocks_risk = msgs_risk[1]["content"]
risk_imgs = [b for b in blocks_risk if b.get("type") == "image_url"]
risk_text = " ".join(b.get("text", "") for b in blocks_risk if b.get("type") == "text")
if not risk_imgs:
risk(
"suggestion_* 缺失且 title 只有裸 URL 时,images 被静默忽略 → 题目信息丢失(不崩溃)",
f"repro: images=[data_url], suggestion_title='', title='<图片URL>';消息中 image_blocks=0,text={risk_text[:60]!r}",
)
else:
record("c-风险变体:suggestion_* 缺失时会回退使用 images", True)
# (e) 纯文字题
qe = QuestionInfo(title="2 + 3 = ?", type="single", options="A. 4\nB. 5\nC. 6\nD. 7",
images=[], suggestion_title="", suggestion_options="")
msgs_e = _ocs_vision_messages(qe, [])
record("e) 纯文字题:消息中无图片块",
[b for b in msgs_e[1]["content"] if b.get("type") == "image_url"] == [])
# (f) 旧 payload 的 URL 抽取仍正常
from ai_client import _extract_image_urls
record("f) 旧 payload:能从 title 抽出本地图片 URL",
_extract_image_urls("题目 https://127.0.0.1:1/x.png") == ["https://127.0.0.1:1/x.png"])
# (g) 真实 OCS 细节:GM_xmlhttpRequest 回退路径下,normalizeToPngDataUrl 只改前缀不转码,
# 可能出现「JPEG 字节 + data:image/png 前缀」。中间件原样透传,需确认可被视觉模型接受。
from ai_client import _normalize_ocs_image
jpeg_bytes = make_jpg("Solve for x: 3x + 5 = 20")
mismatched_jpeg_url = "data:image/png;base64," + base64.b64encode(jpeg_bytes).decode("ascii")
record("g) 中间件接受「JPEG 字节 + PNG 前缀」的 data URL(OCS GM 回退形态)",
_normalize_ocs_image(mismatched_jpeg_url) == mismatched_jpeg_url)
# (d) 大图片素材:2 张约 1MB(base64) 噪声图 + 题干图
noise1 = make_noise_png(512, seed=111)
noise2 = make_noise_png(512, seed=222)
noise1_url = to_data_url(noise1)
noise2_url = to_data_url(noise2)
info(f"大图素材:noise1 {len(noise1) // 1024}KB(png) / base64 {len(noise1_url) // 1024}KB,"
f"noise2 {len(noise2) // 1024}KB(png) / base64 {len(noise2_url) // 1024}KB")
record("d) 大图 base64 每张约 ≥ 700KB(构造大 payload 前提)",
len(noise1_url) >= 700_000 and len(noise2_url) >= 700_000,
f"{len(noise1_url)} / {len(noise2_url)}")
return {
"q_png": q_png,
"q_data_url": q_data_url,
"digit_pngs": digit_pngs,
"noise1_url": noise1_url,
"noise2_url": noise2_url,
"mismatched_jpeg_url": mismatched_jpeg_url,
}
# ────────────────────────── 本地图片服务(f 场景用) ──────────────────────────
class _ImageHandler(BaseHTTPRequestHandler):
png = b""
requests = []
def log_message(self, fmt, *args):
return
def do_GET(self): # noqa: N802
referer = self.headers.get("Referer") or ""
if self.path.split("?")[0] == "/q.png":
self.requests.append({"path": "/q.png", "referer": referer})
self.send_response(200)
self.send_header("Content-Type", "image/png")
self.send_header("Content-Length", str(len(self.png)))
self.end_headers()
self.wfile.write(self.png)
else:
self.requests.append({"path": self.path, "referer": referer})
self.send_response(404)
self.end_headers()
def start_image_server(png: bytes):
handler_cls = type("ImageHandler", (_ImageHandler,), {"png": png, "requests": []})
server = ThreadingHTTPServer(("127.0.0.1", 0), handler_cls)
server.daemon_threads = True
thread = threading.Thread(target=server.serve_forever, name="adv-image-server", daemon=True)
thread.start()
return server, handler_cls, server.server_address[1]
# ────────────────────────── E2E 对抗场景 ──────────────────────────
def e2e_checks(images: dict) -> None:
print("\n[E2E] 真实调用 POST /api/search(会消耗少量 DeepSeek 额度)")
from fastapi.testclient import TestClient
from server import app
q_data_url = images["q_data_url"]
digit = {k: to_data_url(v) for k, v in images["digit_pngs"].items()}
scenarios = []
# a) markers 数量 > images 数量
scenarios.append(
{
"id": "a",
"label": "markers([图片3]) 超出 images 数量",
"payload": {
"title": "原始标题(含坏 URL) https://p.ananas.chaoxing.com/star3/origin/not-found.png",
"type": "single",
"options": "A. 3\nB. 5\nC. 7\nD. 9",
"images": [q_data_url],
"suggestion_title": "请解方程: [图片1] 另外 [图片3] ",
"suggestion_options": "",
},
"expect_images_log": 1,
}
)
# b) images 含非字符串 / 空值
scenarios.append(
{
"id": "b",
"label": "images 含 None/空串/数字/dict/undefined",
"payload": {
"title": "原始标题",
"type": "single",
"options": "A. 3\nB. 5\nC. 7\nD. 9",
"images": [None, "", "undefined", 123, {"oops": 1}, q_data_url],
"suggestion_title": "请解方程: [图片1] ",
"suggestion_options": "",
},
"expect_images_log": 6,
}
)
# c) suggestion_title/options 缺失但 images 有值(题面本身是纯文字,应能答对)
scenarios.append(
{
"id": "c",
"label": "suggestion_* 缺失但 images 有值(按纯文字题退化)",
"payload": {
"title": "Solve for x: 3x + 5 = 20",
"type": "single",
"options": "A. 3\nB. 5\nC. 7\nD. 9",
"images": [q_data_url],
},
"expect_images_log": 1,
}
)
# e) 纯文字题(OCS 正常形态:images=[]、suggestion_* 空串)
scenarios.append(
{
"id": "e",
"label": "纯文字题 images=[] suggestion_*=''",
"payload": {
"title": "2 + 3 = ?",
"type": "single",
"options": "A. 4\nB. 5\nC. 6\nD. 7",
"images": [],
"suggestion_title": "",
"suggestion_options": "",
},
"expect_images_log": 0,
}
)
# d) 大 payload:2 张 ~1MB base64 噪声图 + 1 张题干图([图片3])
large_payload = {
"title": "请阅读题目图片后作答",
"type": "single",
"options": "A. 3\nB. 5\nC. 7\nD. 9",
"images": [images["noise1_url"], images["noise2_url"], q_data_url],
"suggestion_title": "请阅读题目: [图片3] ",
"suggestion_options": "",
}
import json as _json
est_size = len(_json.dumps(large_payload))
info(f"d) 大 payload 预计 JSON 大小 ≈ {est_size / 1048576:.2f} MB")
record("d) 大 payload 体积 > 2MB", est_size > 2 * 1024 * 1024, f"{est_size} bytes")
scenarios.append(
{
"id": "d",
"label": "大 payload(2×~1MB base64 + 题干图)",
"payload": large_payload,
"expect_images_log": 3,
}
)
# g) 真实 OCS 细节:JPEG 字节 + data:image/png 前缀(GM_xmlhttpRequest 回退路径的形态)
scenarios.append(
{
"id": "g",
"label": "JPEG 字节 + PNG 前缀的 data URL(OCS GM 回退路径)",
"payload": {
"title": "请阅读题目图片:",
"type": "single",
"options": "A. 3\nB. 5\nC. 7\nD. 9",
"images": [images["mismatched_jpeg_url"]],
"suggestion_title": "请阅读题目图片: [图片1] ",
"suggestion_options": "",
},
"expect_images_log": 1,
}
)
# f) 旧版 payload:无 images/suggestion 字段,title 里是图片 URL → URL 下载视觉链路
server, handler_cls, port = start_image_server(images["q_png"])
q_url = f"http://127.0.0.1:{port}/q.png"
scenarios.append(
{
"id": "f",
"label": "旧版 payload(无 images)→ URL 下载链路",
"payload": {
"title": f"{q_url}",
"type": "single",
"options": "A. 3\nB. 5\nC. 7\nD. 9",
},
# 旧 payload 没有 images 字段,日志里不会出现 "<N 张 base64 图片>"
"expect_images_log": None,
}
)
results = []
ocs_logger = logging.getLogger("ocs_ai")
ocs_logger.addHandler(CAPTURE)
try:
# 必须用 with 触发 FastAPI lifespan
with TestClient(app) as client:
for sc in scenarios:
print(f"\n [{sc['id']}] {sc['label']}")
CAPTURE.reset()
try:
resp = client.post("/api/search", json=sc["payload"])
data = resp.json() if resp.status_code == 200 else {}
except Exception as exc:
import traceback
traceback.print_exc()
results.append({"id": sc["id"], "ok": False, "error": repr(exc)})
print(f" POST 异常: {exc!r}")
continue
answer = str(data.get("answer", "")) if isinstance(data, dict) else ""
norm = normalize_answer(answer)
ok_http = resp.status_code == 200 and data.get("code") == 1
ok = ok_http and norm == "B"
print(f" HTTP {resp.status_code} code={data.get('code')!r} "
f"answer={answer!r} 归一化={norm!r} 期望='B' → {'PASS' if ok else 'FAIL'}")
if not ok_http and data.get("msg"):
print(f" msg: {str(data.get('msg'))[:300]}")
log_ok = check_logs(f"[{sc['id']}]", expect_images=sc.get("expect_images_log"))
results.append({"id": sc["id"], "ok": ok and log_ok, "answer": answer, "norm": norm,
"log_ok": log_ok})
finally:
ocs_logger.removeHandler(CAPTURE)
server.shutdown()
server.server_close()
# f 场景:旧链路应带 Referer 请求本地图片
reqs = list(handler_cls.requests)
print("\n [f] 本地图片服务请求记录:")
for r in reqs:
print(f" GET {r['path']} | Referer={r['referer']!r}")
record("f) 旧链路下载图片时带了 chaoxing Referer",
any("chaoxing" in (r["referer"] or "").lower() for r in reqs),
f"requests={len(reqs)}")
passed = sum(1 for r in results if r["ok"])
print(f"\n E2E 对抗场景: PASS {passed}/{len(results)}")
for r in results:
if not r["ok"]:
record(f"场景 {r['id']} 通过", False, f"answer={r.get('answer')!r} log_ok={r.get('log_ok')}")
# ────────────────────────── main ──────────────────────────
def main() -> int:
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
print("=" * 74)
print("OCS 5.0 图片题优化 · 对抗性测试" + ("(仅离线预检)" if OFFLINE_ONLY else "(离线预检 + 真实 E2E)"))
print("=" * 74)
images = offline_checks()
if not OFFLINE_ONLY:
e2e_checks(images)
print("\n" + "=" * 74)
if RISKS:
print(f"风险提示 {len(RISKS)} 条(不阻断,见上方 [RISK]):")
for r in RISKS:
print(f" - {r}")
if FAILS:
print(f"总体: FAIL ❌ ({len(FAILS)} 项失败)")
for name in FAILS:
print(f" - {name}")
print("=" * 74)
return 1
print("总体: PASS ✅" + ("(离线预检)" if OFFLINE_ONLY else f"(对抗场景全部通过)"))
print("=" * 74)
return 0
if __name__ == "__main__":
sys.exit(main())