#!/usr/bin/env python # -*- coding: utf-8 -*- """ OCS 5.0「图片题优化」契约测试(纯离线,不联网、不花钱) 目标 ---- 独立核对三件事: 1. generate_ocs_config.generate_config() 生成的题库配置,一定能让 OCS 5.0 的 isAnswererWrapperSupportImageOptimize() 返回 true(即不再弹「当前题库配置暂不支持 图片、公式等题目处理」警告); 2. OCS 5.0 运行时实际发出的 POST JSON 结构,与 ai_client / server 的实现假设一致 (images 元素是 data:image/png;base64,...;suggestion_* 用 " [图片N] " 带空格; text-only 时 images=[]、suggestion_* 为空字符串); 3. server.py 对新字段的解析(缺失/类型异常)与 ai_client 的容错行为符合预期。 实现方式 -------- * 为避免「用自己的 Python 复刻来验证自己的假设」,本测试用 Node 真实执行 tests/ocs5_emulator.mjs —— 该文件逐段移植自本地 OCS 5.0 源码(函数来源写在文件头), 保留 JS 的 RegExp / Function / JSON 等真实语义; * 配置检测与 payload 构建结果直接来自 emulator;Python 侧只做断言和对接; * server 端用 TestClient + 假 AI 客户端(不调用 DeepSeek),只验证字段解析链路; * 最后对 OCS 源码文本做静态断言,确认 emulator 移植的关键逻辑与源码字符串一致 (源码目录不存在时这些检查标记 SKIP,不影响其他检查)。 运行(项目根目录): $env:PYTHONIOENCODING='utf-8' py tests/test_ocs5_contract.py """ from __future__ import annotations import base64 import io import json import logging import os import re import shutil import subprocess import sys from pathlib import Path # ────────────────────────── 基础设置 ────────────────────────── ROOT = Path(__file__).resolve().parent.parent TESTS_DIR = Path(__file__).resolve().parent EMULATOR = TESTS_DIR / "ocs5_emulator.mjs" if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) os.chdir(ROOT) # OCS 5.0 源码目录:可用环境变量 OCS5_SRC 覆盖 OCS_SRC_CANDIDATES = [ os.environ.get("OCS5_SRC", ""), r"C:\Users\SiF4.PETRI-DISH\AppData\Local\Temp\ocs_src\ocsjs_exp_5.0\ocsjs-5.0", r"C:\Users\SiF4~1.PET\AppData\Local\Temp\ocs_src\ocsjs_exp_5.0\ocsjs-5.0", ] FAILS = [] SKIPS = [] 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 skip(name: str, reason: str) -> None: print(f" [SKIP] {name} | {reason}") SKIPS.append(name) def find_ocs_src() -> Path | None: for cand in OCS_SRC_CANDIDATES: if cand and Path(cand).is_dir(): return Path(cand) return None # ────────────────────────── 测试图片 ────────────────────────── FONT_CANDIDATES = [ r"C:\Windows\Fonts\arial.ttf", r"C:\Windows\Fonts\arialbd.ttf", r"C:\Windows\Fonts\segoeui.ttf", ] def make_png_data_url(text: str, width: int = 700, height: int = 130, font_size: int = 44) -> str: from PIL import Image, ImageDraw, ImageFont font = None for path in FONT_CANDIDATES: if os.path.isfile(path): try: font = ImageFont.truetype(path, font_size) break except Exception: continue if font is None: font = ImageFont.load_default() img = Image.new("RGB", (width, height), "white") draw = ImageDraw.Draw(img) draw.text((30, height // 2 - font_size // 2 - 6), text, fill="black", font=font) buf = io.BytesIO() img.save(buf, format="PNG") return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii") # ────────────────────────── Node emulator 调用 ────────────────────────── def node_available() -> tuple: node = shutil.which("node") return (node is not None, node or "") def run_emulator(payload: dict) -> dict: node_ok, node = node_available() if not node_ok: raise RuntimeError("未找到 node,可执行文件不可用") proc = subprocess.run( [node, str(EMULATOR)], input=json.dumps(payload, ensure_ascii=False).encode("utf-8"), capture_output=True, timeout=180, ) if proc.returncode != 0: raise RuntimeError( f"emulator 执行失败 rc={proc.returncode}: " f"{proc.stderr.decode('utf-8', 'replace')[:2000]}" ) stderr = proc.stderr.decode("utf-8", "replace").strip() if stderr: print(f" (emulator stderr) {stderr[:300]}") return json.loads(proc.stdout.decode("utf-8")) # ══════════════════════════════════════════════════════════════════ # 一、题库配置检测(generate_ocs_config → OCS 检测逻辑) # ══════════════════════════════════════════════════════════════════ OLD_CONFIG = [ { "url": "http://127.0.0.1:8777/api/search", "name": "AI 智能搜题", "method": "post", "contentType": "json", "data": {"title": "${title}", "type": "${type}", "options": "${options}"}, "handler": "return (res)=> res.code === 1 ? [res.question, res.answer] : undefined", } ] def section_config() -> tuple: print("\n[一] 题库配置检测(OCS 5.0 isAnswererWrapperSupportImageOptimize)") from generate_ocs_config import generate_config generated = generate_config() # 1.1 generate_config() 与仓库里的 ocs_config.json 是否一致 config_file = ROOT / "ocs_config.json" if config_file.is_file(): try: file_data = json.loads(config_file.read_text(encoding="utf-8")) record( "generate_config() 与 ocs_config.json 内容一致", file_data == generated, "" if file_data == generated else "请重新运行 gen_config.bat 生成最新 ocs_config.json", ) except Exception as exc: record("ocs_config.json 可解析", False, repr(exc)) else: skip("ocs_config.json 一致性", "文件不存在") # 1.2 用 Node 执行 OCS 原版检测逻辑 case_wrappers = [ { "name": "旧配置(无图片字段)", "wrapper": OLD_CONFIG[0], }, { "name": "GET + 占位符(方法不对)", "wrapper": {"url": "http://x/api", "name": "x", "method": "get", "data": {"images": "${images}"}, "handler": "return (r)=> r"}, }, { "name": "method 大写 POST(源码 toLowerCase 后应通过)", "wrapper": {"url": "http://x/api", "name": "x", "method": "POST", "data": {"suggestion_title": "${suggestion_title}"}, "handler": "return (r)=> r"}, }, { "name": "POST 但 data 无任何图片引用(应不支持)", "wrapper": {"url": "http://x/api", "name": "x", "method": "post", "data": {"title": "${title}", "options": "${options}"}, "handler": "return (r)=> r"}, }, { "name": "仅 data.images handler 引用 env.images(应支持)", "wrapper": {"url": "http://x/api", "name": "x", "method": "post", "data": {"images": {"handler": "return (env)=> env.images || []"}}, "handler": "return (r)=> r"}, }, { "name": "handler 中 env.titleImages 不应误判(负向)", "wrapper": {"url": "http://x/api", "name": "x", "method": "post", "data": {"images": {"handler": "return (env)=> env.titleImages || []"}}, "handler": "return (r)=> r"}, }, { "name": "handler 中 env.imagesList 不应误判(负向)", "wrapper": {"url": "http://x/api", "name": "x", "method": "post", "data": {"images": {"handler": "return (env)=> env.imagesList || []"}}, "handler": "return (r)=> r"}, }, { "name": "解构写法 const { images } = env(应支持)", "wrapper": {"url": "http://x/api", "name": "x", "method": "post", "data": {"images": {"handler": "return (env)=> { const { images } = env; return images; }"}}, "handler": "return (r)=> r"}, }, ] if not node_available()[0]: skip("OCS 检测逻辑执行", "未找到 node(需 Node.js 运行 ocs5_emulator.mjs)") return generated try: detect = run_emulator({"op": "detect", "wrappers": generated, "cases": case_wrappers}) except Exception as exc: record("Node emulator 检测执行", False, repr(exc)) return generated # 1.3 新配置 / 旧配置的总体判定 record( "新配置通过 OCS 图片题优化检测(警告消失)", detect.get("supportsImageOptimize") is True, f"per-wrapper={[w['supportsImageOptimize'] for w in detect.get('perWrapper', [])]}", ) old_detect = run_emulator({"op": "detect", "wrappers": OLD_CONFIG}) record( "旧配置会被判定为不支持(复现「暂不支持图片、公式」警告条件)", old_detect.get("supportsImageOptimize") is False, ) # 1.4 每个 wrapper 的检测 + 解析器校验 + 可疑代码扫描 per = detect.get("perWrapper", []) record("新配置每个 wrapper 均通过图片检测", len(per) == 1 and per[0]["supportsImageOptimize"] is True) record( "新配置通过 OCS AnswerWrapperParser.fromObject 字段校验", all(w["parser"]["ok"] for w in per), f"errors={[e for w in per for e in w['parser']['errors']]}", ) all_risks = [risks for w in per for risks in w["risks"]] record( "新配置不会触发 OCS「可疑代码」风险弹窗", len(all_risks) == 0, f"findings={all_risks}", ) # 1.5 边界/负向用例 case_results = {c["name"]: c for c in detect.get("directCases", [])} expected = { "旧配置(无图片字段)": False, "GET + 占位符(方法不对)": False, "method 大写 POST(源码 toLowerCase 后应通过)": True, "POST 但 data 无任何图片引用(应不支持)": False, "仅 data.images handler 引用 env.images(应支持)": True, "handler 中 env.titleImages 不应误判(负向)": False, "handler 中 env.imagesList 不应误判(负向)": False, "解构写法 const { images } = env(应支持)": True, } for name, want in expected.items(): got = (case_results.get(name) or {}).get("result") record(f"检测边界:{name}", got is want, f"got={got}") # 1.6 粘贴字符串解析路径(AnswerWrapperParser.from(string) 只做 JSON.parse) raw = json.dumps(generated, ensure_ascii=False) parse = run_emulator({"op": "parse", "wrappers": generated, "rawString": raw}) record("配置 JSON 字符串可被 OCS 解析", parse.get("stringParseOk") is True) record("配置通过 fromObject 校验", parse.get("fromObject", {}).get("ok") is True) return generated # ══════════════════════════════════════════════════════════════════ # 二、OCS 运行时 payload 结构(buildAnswererEnv + POST data 解析) # ══════════════════════════════════════════════════════════════════ def section_payload(generated_config: list) -> list: print("\n[二] OCS 5.0 运行时 POST JSON 结构(buildAnswererEnv + data 解析)") question_png = make_png_data_url("Solve for x: 3x + 5 = 20") digit_pngs = {d: make_png_data_url(d, 220, 150, 64) for d in ("3", "5", "7", "9")} q_url = "https://p.ananas.chaoxing.com/star3/origin/q.png" opt_urls = [f"https://p.ananas.chaoxing.com/star3/origin/{d}.png" for d in ("3", "5", "7", "9")] scenarios = [ { "name": "纯文字题", "type": "single", "title": "2 + 3 = ?", "options": "A. 4\nB. 5\nC. 6\nD. 7", "titleImages": [], "optionsImages": [], "imagePayloads": {}, }, { "name": "title 图片(DOM 顺序 titleImages)", "type": "single", "title": "Solve for x: 3x + 5 = 20 " + q_url, "options": "A. 3\nB. 5\nC. 7\nD. 9", "titleImages": [q_url], "optionsImages": [], "imagePayloads": {q_url: question_png}, }, { "name": "纯图片选项", "type": "single", "title": "Solve for x: 3x + 5 = 20", "options": "\n".join(f"{letter}. {url}" for letter, url in zip("ABCD", opt_urls)), "titleImages": [], "optionsImages": list(opt_urls), "imagePayloads": {u: digit_pngs[d] for u, d in zip(opt_urls, ("3", "5", "7", "9"))}, }, { "name": "混合多图(title 1 张 + options 4 张)", "type": "single", "title": "求 x 的值: " + q_url, "options": "\n".join(f"{letter}. {url}" for letter, url in zip("ABCD", opt_urls)), "titleImages": [q_url], "optionsImages": list(opt_urls), "imagePayloads": { q_url: question_png, **{u: digit_pngs[d] for u, d in zip(opt_urls, ("3", "5", "7", "9"))}, }, }, ] if not node_available()[0]: skip("OCS payload 构建", "未找到 node") return [] try: built = run_emulator({"op": "build", "wrappers": generated_config, "scenarios": scenarios}) except Exception as exc: record("Node emulator 构建 payload", False, repr(exc)) return [] by_name = {s["name"]: s for s in built.get("scenarios", [])} bodies = [] for scenario in scenarios: name = scenario["name"] item = by_name.get(name) if not item: record(f"payload 构建:{name}", False, "emulator 未返回该场景") continue requests = item.get("requests") or [] if not requests: record(f"payload 构建:{name}", False, "无 wrapper 请求") continue body = requests[0]["body"] bodies.append((name, scenario, body)) ok = True detail = "" # 所有场景公共断言:字段齐全、method 正确、key 与 server 期望一致 ok = ok and requests[0]["method"] == "post" ok = ok and requests[0]["contentType"] == "json" ok = ok and isinstance(body.get("images"), list) ok = ok and isinstance(body.get("suggestion_title"), str) ok = ok and isinstance(body.get("suggestion_options"), str) # images 元素必须是 OCS 归一化后的 data:image/png;base64,... imgs = body.get("images") or [] ok = ok and all( isinstance(x, str) and x.startswith("data:image/png;base64,") and len(x) > 100 for x in imgs ) if not ok: detail = f"method={requests[0]['method']} keys={list(body.keys())} images={len(imgs)}" record(f"payload 结构:{name}", ok, detail) # 逐场景精确断言 if "纯文字题" in by_name: body = by_name["纯文字题"]["requests"][0]["body"] record( "纯文字题:images=[]、suggestion_* 为空字符串(不是 undefined)", body["images"] == [] and body["suggestion_title"] == "" and body["suggestion_options"] == "", f"images={body['images']!r} title={body['suggestion_title']!r} opts={body['suggestion_options']!r}", ) record( "纯文字题:title/type/options 原样保留", body["title"] == "2 + 3 = ?" and body["type"] == "single" and body["options"] == "A. 4\nB. 5\nC. 6\nD. 7", ) if "title 图片(DOM 顺序 titleImages)" in by_name: scenario = by_name["title 图片(DOM 顺序 titleImages)"] body = scenario["requests"][0]["body"] expected_title = ("Solve for x: 3x + 5 = 20 " + q_url).replace(q_url, " [图片1] ") record("title 图片:images 恰好 1 张 base64", len(body["images"]) == 1) record( "title 图片:suggestion_title 用 ' [图片1] ' 原位替换(左右各一空格)", body["suggestion_title"] == expected_title, f"got={body['suggestion_title']!r}", ) record( "title 图片:无选项图片时 suggestion_options 为空字符串", body["suggestion_options"] == "", f"got={body['suggestion_options']!r}", ) if "纯图片选项" in by_name: body = by_name["纯图片选项"]["requests"][0]["body"] expected_options = "\n".join( f"{letter}. [图片{i + 1}] " for i, letter in enumerate("ABCD") ) expected_title = "Solve for x: 3x + 5 = 20" record("纯图片选项:images 恰好 4 张 base64", len(body["images"]) == 4) record( "纯图片选项:suggestion_title 无图片时保持原文", body["suggestion_title"] == expected_title, f"got={body['suggestion_title']!r}", ) record( "纯图片选项:suggestion_options 按 A/B/C/D 顺序填 [图片1..4](带空格)", body["suggestion_options"] == expected_options, f"got={body['suggestion_options']!r}", ) if "混合多图(title 1 张 + options 4 张)" in by_name: body = by_name["混合多图(title 1 张 + options 4 张)"]["requests"][0]["body"] expected_options = "\n".join( f"{letter}. [图片{i + 2}] " for i, letter in enumerate("ABCD") ) record( "混合多图:编号先 title 后 options(title=[图片1],options=[图片2..5])", body["suggestion_title"].endswith(" [图片1] ") and body["suggestion_options"] == expected_options, f"title={body['suggestion_title']!r} opts={body['suggestion_options']!r}", ) # 交叉验证:OCS 产生的 [图片N] 标记必须能被中间件的解析器识别 from ai_client import _iter_marker_segments, _prepare_ocs_images segs = _iter_marker_segments("前 [图片12] 后") record( "中间件能解析 OCS 生成的 ' [图片N] ' 标记", [(k, v) for k, v in segs if k == "image"] == [("image", 12)], f"segments={segs}", ) record( "中间件 _prepare_ocs_images 接受 OCS 的 data URL 数组", len(_prepare_ocs_images([question_png, digit_pngs["5"]])) == 2, ) return bodies # ══════════════════════════════════════════════════════════════════ # 三、server 端字段解析(TestClient + 假 AI,不联网) # ══════════════════════════════════════════════════════════════════ class FakeAIClient: """记录 server 传进来的 QuestionInfo,不调用 DeepSeek""" def __init__(self): self.questions = [] async def ask(self, question): self.questions.append(question) return "B" 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("") def reset(self): self.messages = [] def section_server(bodies: list) -> None: print("\n[三] server.py 新字段解析(TestClient + 假 AI,不联网)") from fastapi.testclient import TestClient import server as server_module question_png = make_png_data_url("1 + 1 = ?") try: client_ctx = TestClient(server_module.app) with client_ctx as client: fake = FakeAIClient() server_module.ai_client = fake health = client.get("/health") record("server 启动 /health 正常", health.status_code == 200 and health.json().get("status") == "ok") # 3.1 OCS emulator 产出的真实结构 → server → QuestionInfo for name, scenario, body in bodies: before = len(fake.questions) resp = client.post("/api/search", json=body) data = resp.json() if resp.status_code == 200 else {} ok = resp.status_code == 200 and data.get("code") == 1 record(f"server 接受 OCS payload:{name}", ok, f"HTTP {resp.status_code} {str(data)[:120]}") if len(fake.questions) > before: q = fake.questions[-1] # server.py 对 title/options/suggestion_* 做了 .strip()(首尾空白), # 标记 " [图片N] " 在正文中间,不会被破坏。 record( f"server 正确透传新字段:{name}", q.images == body.get("images") and q.suggestion_title == (body.get("suggestion_title") or "").strip() and q.suggestion_options == (body.get("suggestion_options") or "").strip() and q.title == (body.get("title") or "").strip() and q.options == (body.get("options") or "").strip(), f"images={len(q.images)} sug_title={q.suggestion_title[:40]!r}", ) # 3.2 字段异常/缺失的容器行为(server 只做容器归一化,不做语义修正) abnormal = [ ( "旧版 payload(无 images/suggestion)", {"title": "2 + 3 = ?", "type": "single", "options": "A. 4\nB. 5"}, lambda q: q.images == [] and q.suggestion_title == "" and q.suggestion_options == "", ), ( "images 不是 list(字符串)", {"title": "t", "type": "single", "options": "A. 1\nB. 2", "images": "data:image/png;base64,AAA"}, lambda q: q.images == [], ), ( "images 不是 list(dict)", {"title": "t", "type": "single", "options": "A. 1\nB. 2", "images": {"0": "x"}}, lambda q: q.images == [], ), ( "images 含非字符串/空值", {"title": "t", "type": "single", "options": "A. 1\nB. 2", "images": [None, "", 123, {}, question_png]}, lambda q: isinstance(q.images, list) and len(q.images) == 5, ), ( "suggestion_* 为数字/None", {"title": "t", "type": "single", "options": "A. 1\nB. 2", "images": [question_png], "suggestion_title": 123, "suggestion_options": None}, lambda q: q.suggestion_title == "123" and q.suggestion_options == "", ), ( "suggestion_* 为 'undefined' 字面量(旧 OCS 可能残留)", {"title": "t", "type": "single", "options": "A. 1\nB. 2", "images": [], "suggestion_title": "undefined", "suggestion_options": "undefined"}, lambda q: q.suggestion_title == "undefined" and q.suggestion_options == "undefined", ), ] for name, payload, predicate in abnormal: before = len(fake.questions) resp = client.post("/api/search", json=payload) data = resp.json() if resp.status_code == 200 else {} ok_http = resp.status_code == 200 and data.get("code") == 1 ok = ok_http and len(fake.questions) > before and predicate(fake.questions[-1]) record(f"server 字段容错:{name}", ok, f"HTTP {resp.status_code} code={data.get('code')}") # 3.3 ai_client 对 server 透传的异常值继续兜底 from ai_client import _clean_ocs_text, _normalize_ocs_image, _prepare_ocs_images record("ai_client._clean_ocs_text 把 'undefined'/'null'/非字符串当空", _clean_ocs_text("undefined") == "" and _clean_ocs_text("null") == "" and _clean_ocs_text(None) == "" and _clean_ocs_text(123) == "") record("ai_client._normalize_ocs_image 过滤空/非字符串/非 data URL", _normalize_ocs_image(None) == "" and _normalize_ocs_image("") == "" and _normalize_ocs_image("undefined") == "" and _normalize_ocs_image(123) == "" and _normalize_ocs_image(question_png) == question_png) filtered = _prepare_ocs_images([None, "", "undefined", 123, {}, question_png]) record("ai_client._prepare_ocs_images 过滤后保留有效图", filtered == [question_png], f"len={len(filtered)}") # 3.4 日志脱敏:base64 不得进入日志 capture = LogCapture() ocs_logger = logging.getLogger("ocs_ai") ocs_logger.addHandler(capture) try: big = "data:image/png;base64," + "A" * 200_000 capture.reset() resp = client.post( "/api/search", json={"title": "日志脱敏测试", "type": "single", "options": "A. 1\nB. 2", "images": [big], "suggestion_title": " [图片1] ", "suggestion_options": ""}, ) messages = list(capture.messages) finally: ocs_logger.removeHandler(capture) joined = "\n".join(messages) body_logs = [m for m in messages if "POST 请求体" in m] record( "日志脱敏:base64 图片不进日志", "AAAAAAAA" not in joined and "base64," not in joined.split("POST 请求体", 1)[-1][:200], f"maxlogline={max((len(m) for m in messages), default=0)}", ) record( "日志脱敏:images 字段显示为 ''", any("<1 张 base64 图片>" in m for m in body_logs), str(body_logs[:1])[:200], ) record( "日志脱敏:单条日志长度 < 5000 字符", max((len(m) for m in messages), default=0) < 5000, f"max={max((len(m) for m in messages), default=0)}", ) # 3.5 GET 接口(旧客户端)仍可用 before = len(fake.questions) resp = client.get("/api/search", params={"title": "1 + 1 = ?", "type": "single", "options": "A. 2\nB. 3"}) data = resp.json() if resp.status_code == 200 else {} record("旧版 GET /api/search 仍兼容", resp.status_code == 200 and data.get("code") == 1) except Exception as exc: import traceback traceback.print_exc() record("server 字段解析测试执行", False, repr(exc)) # ══════════════════════════════════════════════════════════════════ # 四、OCS 源码静态断言(确认 emulator 移植与源码一致) # ══════════════════════════════════════════════════════════════════ def section_source() -> None: print("\n[四] OCS 5.0 源码静态核对(确认判定条件 / payload 格式)") src = find_ocs_src() if src is None: skip("OCS 源码静态核对", "未找到源码目录(可用 OCS5_SRC 环境变量指定)") return env_ts_path = src / "packages/scripts/src/utils/answerer-env.ts" handler_ts_path = src / "packages/core/src/core/answer-wrapper/answer.wrapper.handler.ts" common_ts_path = src / "packages/scripts/src/projects/common.ts" parser_ts_path = src / "packages/core/src/core/answer-wrapper/answer.wrapper.parser.ts" request_ts_path = src / "packages/core/src/core/utils/request.ts" texts = {} for key, path in [ ("env", env_ts_path), ("handler", handler_ts_path), ("common", common_ts_path), ("parser", parser_ts_path), ("request", request_ts_path), ]: if path.is_file(): texts[key] = path.read_text(encoding="utf-8") # 4.1 检测逻辑关键条件 env = texts.get("env", "") record("源码:检测要求 method 为 post", "String(wrapper.method || 'get').toLowerCase() !== 'post'" in env) record("源码:检测三个图片占位符", all(p in env for p in ('`\\${images}`', '`\\${suggestion_title}`', '`\\${suggestion_options}`'))) record("源码:data 字段 handler 的 env 引用正则", "IMAGE_ENV_REF_PATTERN = /(^|[^\\w$])(images|suggestion_title|suggestion_options)(?![\\w$])/" in env) record("源码:仅检测 data 字段值(字符串占位符 / 对象 handler)", "typeof value === 'string'" in env and "typeof (value as any).handler === 'string'" in env) # 4.2 env 构建:无图时不注入 images/suggestion_* record("源码:仅当 createImageSuggestion 有结果才注入 env.images", "if (suggestion) {" in env and "env.images = suggestion.images" in env) record("源码:suggestion_options 仅在有选项图片时设置", "if (suggestion.suggestion_options !== undefined)" in env) # 4.3 占位符格式:' [图片N] ',左右各一个空格 line = "" for raw_line in env.splitlines(): if "placeholderMap.set(url" in raw_line: line = raw_line.strip() break record("源码:占位符模板为 ' [图片${images.length + 1}] '(左右各一空格)", " [图片${images.length + 1}] " in line, line) # 用源码里的模板生成真实标记,喂给中间件解析器 real_marker = " [图片7] " from ai_client import _iter_marker_segments segs = _iter_marker_segments("A" + real_marker + "B") record("中间件能识别源码格式的标记(含空格)", [(k, v) for k, v in segs if k == "image"] == [("image", 7)], str(segs)) # 4.4 运行时无图时 env 默认值(避免 undefined 字面量) handler = texts.get("handler", "") record("源码:无图时 env.images 默认 []", "if (env.images === undefined) env.images = [];" in handler) record("源码:无图时 suggestion_title 默认 ''", "if (env.suggestion_title === undefined) env.suggestion_title = '';" in handler) record("源码:无图时 suggestion_options 默认 ''", "if (env.suggestion_options === undefined) env.suggestion_options = '';" in handler) # 4.5 POST data 解析:data.* handler 用 Function 执行,字符串走占位符替换 record("源码:POST data 字段 handler 由 Function 动态执行", "Function(Reflect.get((wrapperData as any)[key], 'handler'))()" in handler) record("源码:POST data 字符串字段走 resolvePlaceHolder", "Reflect.set(data, key, resolvePlaceHolder(wrapperData[key]));" in handler) record("源码:POST 请求体最终 JSON.stringify(data)", "JSON.stringify(data)" in texts.get("request", "")) # 4.6 警告逻辑与开关默认值 common = texts.get("common", "") record("源码:警告仅在「图片题优化开启 + 有题库 + 配置不支持」时出现", "isAnswererWrappersSupportImageOptimize(wrappers)" in common and "当前题库配置暂不支持图片、公式等题目处理" in common) record("源码:图片题优化开关默认开启", re.search(r"imageOptimize:\s*\{.*?defaultValue:\s*true", common, re.S) is not None) # 4.7 配置解析:粘贴字符串只做 JSON.parse(不强制 type 字段),fromObject 需要 name/url/handler parser = texts.get("parser", "") record("源码:粘贴 JSON 字符串只走 JSON.parse", "return this.fromJSONString(value);" in parser) record("源码:fromObject 必需字段 name/url/handler", all(f"typeof item.{k} !== 'string'" in parser for k in ("name", "url", "handler"))) record("源码:type 字段缺失不会导致校验失败(仅当存在时校验取值)", "if (item.type && types.every((i) => i !== item.type))" in parser) # 4.8 与 daemon 行为相关的补充说明(打印给报告用) print(" [INFO] 关键结论:") print(" - 判定 = method 必须 post + data 值里有 ${...} 占位符 或 data.*.handler 引用 env 图片字段") print(" - 运行时 env 固定含 title/options/type;无图时 images=[]、suggestion_*=''") print(" - 有图时 images 元素统一为 data:image/png;base64,...,标记格式 ' [图片N] '(N 从 1 起,先 title 后 options)") print(" - suggestion_options 仅在选项含图片时出现(缺失时 handler 会补空字符串)") # ══════════════════════════════════════════════════════════════════ 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「图片题优化」契约测试(离线,不调用 DeepSeek)") print("=" * 74) node_ok, node = node_available() print(f"Node.js: {node or '未找到'}") print(f"OCS 源码: {find_ocs_src() or '未找到'}") generated = section_config() bodies = section_payload(generated) section_server(bodies) section_source() print("\n" + "=" * 74) if FAILS: print(f"总体: FAIL ❌ ({len(FAILS)} 项失败)") for name in FAILS: print(f" - {name}") if SKIPS: print(f"跳过 {len(SKIPS)} 项(环境原因)") print("=" * 74) return 1 print(f"总体: PASS ✅" + (f"(跳过 {len(SKIPS)} 项)" if SKIPS else "")) print("=" * 74) return 0 if __name__ == "__main__": sys.exit(main())