# -*- coding: utf-8 -*- """ OCS × AI 中间件 —— AI 客户端(DeepSeek 视觉版) 图片链路(两条,自动选择): 1. OCS 5.0+「图片题优化」:OCS 在浏览器里把图片转成 base64 数组,随请求体 `images` 一起传过来,题目/选项里的图片被替换成 [图片N] 标记 → 中间件直接把 base64 图片块按顺序拼进多模态消息(无需下载,免疫防盗链) 2. 旧版 OCS / 关闭图片题优化时:图片以 URL 文本内嵌在 title / options 里 → 中间件自己抽 URL、带 Referer 下载、转 base64 后发给视觉模型 DeepSeek 模型说明(2026-10): deepseek-flash 支持图片输入(唯一支持视觉的模型,V4.1-Flash) deepseek-v4-pro 只支持文字,传图片会被忽略,图片题会答非所问 OCR 方案(UniRec / onnxruntime)已移除,不再需要本地 OCR 模型。 """ import asyncio import base64 import html import logging import re from dataclasses import dataclass, field from typing import Optional import httpx logger = logging.getLogger("ocs_ai") ANSWER_SEPARATOR = "#" LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" # 请求图片时带上 Referer / UA:超星等图床对防盗链时也能正常取图 _IMG_HEADERS = { "Referer": "https://mooc1.chaoxing.com/", "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" ), "Accept": "image/avif,image/webp,image/png,image/jpeg,image/gif,*/*;q=0.8", } # DeepSeek 视觉接口支持的图片格式 _IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg") # 没有扩展名也当图片尝试的图床域名关键字 _IMAGE_HOST_HINTS = ( "ananas.chaoxing.com", "chaoxing.com", "zhihuishu.com", "icve.com.cn", "yuketang.cn", "icourse163.org", "xuetangx.com", "unipus.cn", ) _URL_PATTERN = r"""https?://[^\s"'<>()()\[\]{},。;、!?“”‘’]+""" # 按出现顺序切分文本: / markdown 图片 / 裸链接 _URL_JOINED_RE = re.compile( r"""]*?\bsrc\s*=\s*["']?(?P[^"'\s>]+)[^>]*>""" r"""|!\[[^\]]*\]\(\s*(?P[^)\s]+)\s*\)""" r"""|(?P""" + _URL_PATTERN + r""")""", re.I, ) # 单张图片上限(DeepSeek 接口限制 32MB,这里留点余量) _MAX_IMAGE_BYTES = 24 * 1024 * 1024 # base64 编码后的图片总量上限(请求体限制 48MiB) _MAX_TOTAL_ENCODED_BYTES = 40 * 1024 * 1024 _DOWNLOAD_CONCURRENCY = 6 # OCS 5.0「图片题优化」:图片以 base64 数组上传,题目文本中的图片被替换成 [图片N] 标记 _IMAGE_MARK_RE = re.compile(r"\[图片\s*(\d+)\]") _MAX_OCS_IMAGES = 20 # OCS 会把这些空值占位符替换成字面量字符串,这里统一当空处理 _EMPTY_LITERALS = ("", "undefined", "null", "none") @dataclass class QuestionInfo: title: str type: str options: str # ── 以下字段来自 OCS 5.0「图片题优化」 ── images: list[str] = field(default_factory=list) # base64 data URL 数组,顺序对应 [图片1]、[图片2]… suggestion_title: str = "" # 标题里图片 URL 已替换成 [图片N] suggestion_options: str = "" # 选项里图片 URL 已替换成 [图片N] @dataclass class PreparedImage: """一张题图在本地的准备结果""" url: str data_url: Optional[str] = None # 成功下载并编码后的 data URL is_image: bool = True # False 表示下载到的其实不是图片(按文字处理) @property def ok(self) -> bool: return bool(self.data_url) # ═══════════════ 图片 URL 抽取 / 文本清洗 ═══════════════ def _normalize_url(url: str) -> str: url = html.unescape(url or "").strip().strip("\"'`") url = url.replace("\\/", "/") # 去掉句末常见标点、右括号等(OCS 的图片链接后面常常紧跟中文标点) return url.rstrip(".,;:!?)]}>、,。;:!? ") def _is_image_url(url: str) -> bool: low = (url or "").lower() if not low.startswith(("http://", "https://")): return False path = low.split("#", 1)[0].split("?", 1)[0] if path.endswith(_IMAGE_EXTS): return True m = re.match(r"https?://([^/:]+)", low) host = m.group(1) if m else "" return any(hint in host for hint in _IMAGE_HOST_HINTS) def _extract_image_urls(text: str) -> list[str]: """从题目文本中抽取图片 URL(按出现顺序去重)。 覆盖 OCS 实际会发来的几种形态: * 裸链接: https://p.ananas.chaoxing.com/star3/origin/xx.png * HTML 图片: * markdown: ![](https://.../xx.png) * 图床无扩展名:https://p.ananas.chaoxing.com/star3/origin/abc123 """ if not text: return [] text = text.replace("\\/", "/") urls: list[str] = [] for m in _URL_JOINED_RE.finditer(text): raw = m.group("img") or m.group("md") or m.group("bare") or "" url = _normalize_url(raw) if not url.startswith(("http://", "https://")): continue # / markdown 里的链接默认当图片;裸链接需要用扩展名或图床域名判断 tagged = bool(m.group("img") or m.group("md")) if not tagged and not _is_image_url(url): continue if url not in urls: urls.append(url) return urls def _clean_fragment(text: str) -> str: """清洗文本片段:解码实体、去掉 HTML 标签、保留换行""" if not text: return "" text = html.unescape(text) text = re.sub(r"(?i)", "\n", text) text = re.sub(r"<[^>]+>", "", text) text = text.replace("\u200b", "").replace("\xa0", " ") text = re.sub(r"[ \t]+", " ", text) text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() def _strip_image_markup(text: str) -> str: """纯文字模式:把图片(及图片链接)替换成占位符 [图]""" if not text: return "" text = text.replace("\\/", "/") text = re.sub(r"(?is)]*>", "[图]", text) text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "[图]", text) for url in _extract_image_urls(text): text = text.replace(url, "[图]") text = re.sub(r"(?:\[图\]\s*){2,}", "[图]", text) text = re.sub(r"[ \t]+", " ", text) return text.strip() def _iter_segments(text: str, known_urls: set) -> list[tuple[str, str]]: """把文本按图片链接位置切成 [('text', ...), ('image', url), ...]""" text = (text or "").replace("\\/", "/") segments: list[tuple[str, str]] = [] pos = 0 for m in _URL_JOINED_RE.finditer(text): raw = m.group("img") or m.group("md") or m.group("bare") or "" url = _normalize_url(raw) if url not in known_urls: continue if m.start() > pos: segments.append(("text", text[pos:m.start()])) segments.append(("image", url)) pos = m.end() if pos < len(text): segments.append(("text", text[pos:])) return segments def _parse_option_list(options_raw: str) -> list[str]: opts = [] for line in (options_raw or "").split("\n"): line = line.strip() if line: opts.append(line) return opts def _option_prefix(index: int, option_text: str) -> str: """选项前缀:如果文本里已经带了 "A." 就不要再重复加""" letter = LETTERS[index] if index < len(LETTERS) else str(index + 1) stripped = (option_text or "").lstrip() if re.match(rf"^{letter}\s*[.、.))::]", stripped, re.I): return "" return f"{letter}. " # ═══════════════ OCS 5.0「图片题优化」payload ═══════════════ def _clean_ocs_text(text) -> str: """OCS 占位符可能被替换成 undefined 字面量,这里统一归一化""" if not isinstance(text, str): return "" text = text.strip() if text.lower() in _EMPTY_LITERALS: return "" return text def _normalize_ocs_image(value) -> str: """把 OCS 传来的图片项归一化成 data URL;无效项返回空字符串""" if not isinstance(value, str): return "" value = value.strip() if not value: return "" if value.startswith("data:image/"): return value # OCS 正常都会带 data: 前缀;万一只有裸 base64 就补一个 PNG 头 probe = value[:200] if len(value) > 64 and re.fullmatch(r"[A-Za-z0-9+/=\s]+", probe): return "data:image/png;base64," + re.sub(r"\s+", "", value) # 个别环境可能给的是图片 URL,交给上面的 URL 下载链路处理 return "" def _prepare_ocs_images(raw) -> list[str]: """筛选 / 限流 OCS 传来的 base64 图片数组""" if not isinstance(raw, (list, tuple)): return [] images: list[str] = [] total_encoded = 0 for item in raw: data_url = _normalize_ocs_image(item) if not data_url: continue size = len(data_url) if total_encoded + size > _MAX_TOTAL_ENCODED_BYTES: logger.warning(f"OCS 图片总量超限,已忽略剩余 {len(raw) - len(images)} 张") break images.append(data_url) total_encoded += size if len(images) >= _MAX_OCS_IMAGES: logger.warning(f"OCS 图片数量超过 {_MAX_OCS_IMAGES} 张,已截断") break return images def _iter_marker_segments(text: str) -> list[tuple[str, int]]: """把含 [图片N] 标记的文本切成 [('text', str) | ('image', N)]""" text = text or "" segments: list[tuple[str, int]] = [] pos = 0 for m in _IMAGE_MARK_RE.finditer(text): if m.start() > pos: segments.append(("text", text[pos:m.start()])) segments.append(("image", int(m.group(1)))) pos = m.end() if pos < len(text): segments.append(("text", text[pos:])) return segments # ═══════════════ 图片下载 ═══════════════ def _sniff_image_mime(data: bytes, content_type: str = "") -> str: """按文件头判断图片类型(DeepSeek 只认 JPEG / PNG / GIF / WebP)""" if not data: return "" if data.startswith(b"\x89PNG\r\n\x1a\n"): return "image/png" if data.startswith(b"\xff\xd8\xff"): return "image/jpeg" if data[:6] in (b"GIF87a", b"GIF89a"): return "image/gif" if data[:4] == b"RIFF" and data[8:12] == b"WEBP": return "image/webp" ct = (content_type or "").split(";")[0].strip().lower() if ct in ("image/jpeg", "image/jpg"): return "image/jpeg" if ct in ("image/png", "image/gif", "image/webp"): return ct return "" async def _download_image(url: str, timeout: float) -> PreparedImage: """下载一张图片并编码成 data URL;失败时返回标记,由上层决定回退策略""" try: async with httpx.AsyncClient( timeout=min(max(timeout, 5.0), 25.0), headers=_IMG_HEADERS, follow_redirects=True, ) as client: resp = await client.get(url) if resp.status_code != 200: logger.warning(f"图片下载失败 HTTP {resp.status_code}: {url[:90]}") return PreparedImage(url, None, True) content = resp.content if not content: logger.warning(f"图片内容为空: {url[:90]}") return PreparedImage(url, None, True) mime = _sniff_image_mime(content, resp.headers.get("content-type", "")) if not mime: logger.info(f"链接不是受支持的图片,按文字处理: {url[:90]}") return PreparedImage(url, None, False) if len(content) > _MAX_IMAGE_BYTES: logger.warning(f"图片过大({len(content) / 1048576:.1f}MB),已跳过: {url[:90]}") return PreparedImage(url, None, True) b64 = base64.b64encode(content).decode("ascii") logger.info(f"图片就绪 {len(content) // 1024}KB {mime}: {url[:70]}") return PreparedImage(url, f"data:{mime};base64,{b64}", True) except Exception as e: logger.warning(f"图片下载异常: {url[:90]} | {e}") return PreparedImage(url, None, True) # ═══════════════ 视觉消息 ═══════════════ def _vision_messages(question: QuestionInfo, images: dict[str, PreparedImage]) -> list[dict]: """构建 OpenAI 兼容的多模态消息:文字 + 图片内容块,保持题目/选项的原始顺序""" blocks: list[dict] = [] known = set(images.keys()) seen_images: set[str] = set() def push_text(text: str) -> None: text = _clean_fragment(text) if not text: return if blocks and blocks[-1].get("type") == "text": blocks[-1]["text"] = (blocks[-1]["text"] + " " + text).strip() else: blocks.append({"type": "text", "text": text}) def push_image(url: str) -> None: if url in seen_images: push_text("[同上图]") return seen_images.add(url) info = images.get(url) if info is not None and not info.is_image: # 服务器返回的不是图片(比如防盗链拦截页),原样保留链接当文字 push_text(url) return if info is None or not info.data_url: # 注意:不要把原链接直接交给 DeepSeek 抓取——抓取失败会导致整个请求 400 push_text("[图片加载失败]") return blocks.append( {"type": "image_url", "image_url": {"url": info.data_url, "detail": "high"}} ) push_text("题目:") for kind, value in _iter_segments(question.title, known): if kind == "text": push_text(value) else: push_image(value) options = _parse_option_list(question.options)[:26] if options: push_text("选项:") for i, opt in enumerate(options): segments = _iter_segments(opt, known) if not segments: push_text(f"{LETTERS[i]}. {_clean_fragment(opt)}") continue first = True for kind, value in segments: if kind == "text": text = _clean_fragment(value) if not text: continue prefix = _option_prefix(i, opt) if first else "" push_text(f"{prefix}{text}") first = False else: if first: push_text(f"{LETTERS[i]}.") first = False push_image(value) qtype = (question.type or "").strip() push_text(_answer_instruction(qtype)) system = ( "你是一个精确的在线课程答题助手。你能够识别图片中的文字、数学公式、图表和选项内容," "并按要求的格式返回答案。" ) return [ {"role": "system", "content": system}, {"role": "user", "content": blocks}, ] def _answer_instruction(qtype: str) -> str: """按题型生成最终作答要求(两种视觉消息构建方式共用)""" qtype = (qtype or "").strip() if qtype == "single": return ( "请仔细阅读上面的题目和选项(含图片中的公式、图表、文字)," "只回答一个正确选项的字母(A/B/C/D),不要解释。" ) if qtype == "multiple": return ( "请仔细阅读上面的题目和选项(含图片中的公式、图表、文字)," f"回答所有正确选项的字母,用 {ANSWER_SEPARATOR} 分隔(例如 A{ANSWER_SEPARATOR}C),不要解释。" ) if qtype == "judgement": return "请根据题目(含图片中的内容)判断该说法正确还是错误,只回答「正确」或「错误」,不要解释。" if qtype == "completion": return "请根据题目(含图片中的内容)填空,直接给出答案,多个空用 # 分隔,不要解释。" return "请根据题目(含图片中的内容)只回答正确答案,不要解释。" def _ocs_vision_messages(question: QuestionInfo, images: list[str]) -> list[dict]: """OCS 5.0「图片题优化」消息:文本里的 [图片N] 标记原位替换成第 N 张 base64 图片""" blocks: list[dict] = [] used_images: set[int] = set() def push_text(text: str) -> None: text = _clean_fragment(text) if not text: return if blocks and blocks[-1].get("type") == "text": blocks[-1]["text"] = (blocks[-1]["text"] + " " + text).strip() else: blocks.append({"type": "text", "text": text}) def push_image(index: int) -> None: if 1 <= index <= len(images): used_images.add(index) blocks.append( {"type": "image_url", "image_url": {"url": images[index - 1], "detail": "high"}} ) else: push_text(f"[图片{index}]") def push_rich_text(text: str) -> None: for kind, value in _iter_marker_segments(text): if kind == "text": push_text(value) else: push_image(value) title_src = _clean_ocs_text(question.suggestion_title) or _strip_image_markup(question.title) push_text("题目:") push_rich_text(title_src) options_src = _clean_ocs_text(question.suggestion_options) or question.options options = _parse_option_list(options_src)[:26] if options: push_text("选项:") for i, opt in enumerate(options): segments = _iter_marker_segments(opt) if not segments: push_text(f"{LETTERS[i]}. {_clean_fragment(opt)}") continue first = True for kind, value in segments: if kind == "text": text = _clean_fragment(value) if not text: continue prefix = _option_prefix(i, opt) if first else "" push_text(f"{prefix}{text}") first = False else: if first: push_text(f"{LETTERS[i]}.") first = False push_image(value) # 兜底:图片数组里的图没有被任何 [图片N] 标记用上(例如题库配置只引用了 ${images} # 而没引用 ${suggestion_title}/${suggestion_options}),也要把图片交给模型,不能静默丢弃 unused = [idx for idx in range(1, len(images) + 1) if idx not in used_images] if unused: push_text("其余图片:" if used_images else "题目图片:") for idx in unused: push_image(idx) push_text(_answer_instruction(question.type)) system = ( "你是一个精确的在线课程答题助手。你能够识别图片中的文字、数学公式、图表和选项内容," "并按要求的格式返回答案。" ) return [ {"role": "system", "content": system}, {"role": "user", "content": blocks}, ] # ═══════════════ 纯文字消息(无图 / 未配置视觉模型时) ═══════════════ def _build_text_prompt(question: QuestionInfo, has_images: bool = False) -> str: title_clean = _strip_image_markup(question.title) options_list = _parse_option_list(question.options)[:26] lines = [] for i, opt in enumerate(options_list): clean = _strip_image_markup(opt) prefix = _option_prefix(i, opt) lines.append(f"{prefix}{clean}" if clean else f"{LETTERS[i]}. [图]") options_text = "\n".join(lines) if not options_text: options_text = "(无)" qtype = (question.type or "").strip() if qtype == "single": prompt = f"题目:{title_clean}\n选项:\n{options_text}\n\n只回答一个正确选项的字母,务必给出答案。" elif qtype == "multiple": prompt = ( f"题目:{title_clean}\n选项:\n{options_text}\n\n" f"回答所有正确选项的字母,用{ANSWER_SEPARATOR}分隔,务必给出答案。" ) elif qtype == "judgement": prompt = f"判断以下说法,回答「正确」或「错误」,务必给出答案。\n\n{title_clean}" elif qtype == "completion": prompt = ( "你是一个答题助手。回答以下填空题,先给出最终答案,然后可以简要说明。\n\n" f"{title_clean}\n\n答案:" ) else: prompt = f"题目:{title_clean}\n选项:\n{options_text}\n\n只回答正确答案,不要解释。" if has_images: prompt += "\n\n(注意:题目中含图片,但当前未启用视觉识别,请尽量根据文字信息作答。)" return prompt def _text_messages(question: QuestionInfo, has_images: bool = False) -> list[dict]: if question.type == "completion": system = "你是一个精确的在线课程答题助手。请按要求格式返回答案。" else: system = ( "你是一个精确的在线课程答题助手。请按要求格式返回答案。" "即使信息不完整,也请选择最可能的选项。" ) return [ {"role": "system", "content": system}, {"role": "user", "content": _build_text_prompt(question, has_images=has_images)}, ] # ═══════════════ AIClient ═══════════════ class AIClient: def __init__( self, api_key: str, api_base: str = "https://api.openai.com/v1", model: str = "deepseek-v4-pro", vision_model: str = "", temperature: float = 0.1, max_tokens: int = 500, timeout: float = 60.0, thinking: bool = True, provider: str = "deepseek", ): self.api_key = api_key self.api_base = api_base.rstrip("/") self.model = model self.vision_model = (vision_model or "").strip() # 兼容旧配置:千问(qwen)用户过去就是直接把图片发给 model,没单独配置视觉模型 if not self.vision_model and provider == "qwen": self.vision_model = model self.temperature = temperature self.max_tokens = max_tokens self.timeout = timeout self.thinking = thinking self.provider = provider def _headers(self) -> dict: return { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", } def _payload(self, messages: list[dict], max_tokens: int, model: str) -> dict: payload = { "model": model, "messages": messages, "temperature": self.temperature, "max_tokens": max_tokens, } if self.thinking: if self.provider == "qwen": payload["chat_template_kwargs"] = {"enable_thinking": True} else: payload["thinking"] = {"type": "enabled"} return payload async def _post(self, payload: dict) -> dict: async with httpx.AsyncClient(timeout=self.timeout) as client: response = await client.post( f"{self.api_base}/chat/completions", json=payload, headers=self._headers(), ) if response.status_code != 200: raise Exception( f"AI API 请求失败: HTTP {response.status_code} - {response.text[:500]}" ) return response.json() async def _chat(self, messages: list[dict], max_tokens: int = 0, model: str = "") -> str: model = model or self.model max_tokens = max_tokens or self.max_tokens data = await self._post(self._payload(messages, max_tokens, model)) choice = (data.get("choices") or [{}])[0] message = choice.get("message") or {} content = (message.get("content") or "").strip() finish_reason = choice.get("finish_reason") # 思考模式可能把 max_tokens 全用在 reasoning 上,导致 content 为空: # 降低思考强度并放大预算重试一次 if not content and finish_reason == "length": logger.warning("AI 返回内容为空(思考被截断),降低思考强度重试...") retry_payload = self._payload(messages, min(max_tokens * 2, 8192), model) if self.provider == "qwen": retry_payload["chat_template_kwargs"] = {"enable_thinking": True} else: retry_payload["reasoning_effort"] = "low" data = await self._post(retry_payload) choice = (data.get("choices") or [{}])[0] message = choice.get("message") or {} content = (message.get("content") or "").strip() reasoning = (message.get("reasoning_content") or "").strip() if reasoning: logger.debug(f"AI 思考: {reasoning[:300]}") return content async def _prepare_images(self, urls: list[str]) -> dict[str, PreparedImage]: semaphore = asyncio.Semaphore(_DOWNLOAD_CONCURRENCY) async def one(url: str): async with semaphore: return await _download_image(url, self.timeout) results = await asyncio.gather(*(one(u) for u in urls)) prepared: dict[str, PreparedImage] = {} total_encoded = 0 for item in results: # 整体请求体限制 48MiB:base64 后过大就舍弃,避免整个请求被拒 if item.data_url: size = len(item.data_url) if total_encoded + size > _MAX_TOTAL_ENCODED_BYTES: logger.warning(f"图片总量超限,已跳过: {item.url[:80]}") item = PreparedImage(item.url, None, True) else: total_encoded += size prepared[item.url] = item return prepared def _max_tokens_for(self, question: QuestionInfo) -> int: mt = self.max_tokens if self.thinking: mt = max(mt, 3000) elif question.type == "completion": mt = max(mt, 1500) return mt async def ask(self, question: QuestionInfo) -> Optional[str]: image_urls: list[str] = [] for text in (question.title, question.options): for url in _extract_image_urls(text): if url not in image_urls: image_urls.append(url) # OCS 5.0「图片题优化」:图片已经是 base64 数组,直接用,无需下载 ocs_images = _prepare_ocs_images(getattr(question, "images", None)) mt = self._max_tokens_for(question) if ocs_images and self.vision_model: logger.info( f"视觉答题(OCS 图片题优化): {len(ocs_images)} 张 base64 图片 → {self.vision_model}" ) messages = _ocs_vision_messages(question, ocs_images) content = await self._chat(messages, max_tokens=mt, model=self.vision_model) elif image_urls and self.vision_model: logger.info(f"视觉答题: {len(image_urls)} 张图片 → {self.vision_model}") prepared = await self._prepare_images(image_urls) ok = sum(1 for item in prepared.values() if item.ok) logger.info(f"图片准备完成: {ok}/{len(image_urls)} 张可用(其余按文字占位处理)") messages = _vision_messages(question, prepared) content = await self._chat(messages, max_tokens=mt, model=self.vision_model) else: if image_urls or ocs_images: logger.warning( f"检测到 {len(ocs_images) or len(image_urls)} 张图片,但未配置 ai.vision_model," "图片将按 [图] 占位处理;请在 config.yaml 中配置 vision_model: deepseek-flash" ) content = await self._chat( _text_messages(question, has_images=bool(image_urls or ocs_images)), max_tokens=mt ) logger.info(f"AI → '{content[:80] if content else '(空)'}'") if not content: logger.warning(f"AI返回空。题目: {_strip_image_markup(question.title)[:200]}") return content if content else None