OCSxAI/tests/test_ocs5_contract.py

769 lines
35 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「图片题优化」契约测试(纯离线,不联网、不花钱)
目标
----
独立核对三件事:
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("<log format error>")
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 字段显示为 '<N 张 base64 图片>'",
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())