From accd1e5ad22d6a4c3c4b6674f367fe926f58f255 Mon Sep 17 00:00:00 2001 From: ChenRui Date: Fri, 24 Jul 2026 16:51:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=97=AE=E7=AD=94=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/image_utils.py | 175 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 143 insertions(+), 32 deletions(-) diff --git a/utils/image_utils.py b/utils/image_utils.py index ac0bc37..3b72dde 100644 --- a/utils/image_utils.py +++ b/utils/image_utils.py @@ -1,20 +1,110 @@ -""" -图片处理工具模块 - 最终版 v4 -核心思路(最简单直接): -1. 找到所有 .jpg/.png 文件名及其周围的 ![图片]( 上下文 -2. 不管周围格式如何,也不管文件名是否被模型改过 -3. 统一提取文件名 -> 与 original_paths 模匹 -> 替换成正确的 ![图片](完整URL) -""" +"""图片处理工具模块。""" import re -from typing import List +from typing import List, Tuple from difflib import SequenceMatcher +IMG_EXTENSIONS = r'(?:jpg|jpeg|png|gif|bmp|webp)' + + +def _dedupe_keep_order(values: List[str]) -> List[str]: + seen = set() + result = [] + for value in values: + cleaned = (value or "").strip().strip("`'\",,。;;") + if not cleaned: + continue + key = cleaned.lower() + if key in seen: + continue + seen.add(key) + result.append(cleaned) + return result + + def extract_image_paths(text: str) -> List[str]: - """从文本中提取图片路径""" - pattern = r'images/[a-zA-Z0-9_\-./]+\.(?:jpg|jpeg|png|gif|bmp|webp)' - matches = re.findall(pattern, text, re.IGNORECASE) - return list(set(matches)) + """从文本中提取图片路径或 URL,并保持原始出现顺序。""" + if not text: + return [] + + patterns = [ + # Markdown 图片:![alt](url) + rf'!\[[^\]\n]*\]\(\s*([^)\s]+?\.{IMG_EXTENSIONS}(?:[?#][^)\s]*)?)\s*\)', + # 完整 URL。 + rf'(https?://[^\s<>"\'\]\)]+?\.{IMG_EXTENSIONS}(?:[?#][^\s<>"\'\]\)]*)?)', + # API 绝对路径。 + rf'(?"\'\]\)]+?\.{IMG_EXTENSIONS}(?:[?#][^\s<>"\'\]\)]*)?)', + # 相对 images 路径。 + rf'(?"\'\]\)]*)?)', + # 裸文件名,兼容检索结果只给文件名的情况。 + rf'(? str: + return re.sub(r'[?#].*$', '', filename or '').strip() + + +def _split_filename(filename: str) -> Tuple[str, str]: + filename = _strip_url_suffix(filename).lower() + if "." not in filename: + return filename, "" + stem, ext = filename.rsplit(".", 1) + return stem, ext + + +def _path_to_image_url(path: str, base_prefix: str) -> str: + cleaned = (path or "").strip() + if cleaned.startswith(("http://", "https://", "/")): + return cleaned + if cleaned.lower().startswith("images/"): + return base_prefix.rstrip("/") + "/" + cleaned + return base_prefix.rstrip("/") + "/images/" + cleaned.split("/")[-1] + + +def _is_acceptable_image_match(generated_filename: str, original_filename: str, score: float) -> bool: + """只接受完全一致、明确截断或高度相似的文件名,避免把幻觉图片错配到真实图片。""" + generated_stem, generated_ext = _split_filename(generated_filename) + original_stem, original_ext = _split_filename(original_filename) + + if not generated_stem or not original_stem or generated_ext != original_ext: + return False + if generated_stem == original_stem: + return True + if len(generated_stem) >= 12 and original_stem.startswith(generated_stem): + return True + if len(original_stem) >= 12 and generated_stem.startswith(original_stem): + return True + return score >= 0.85 + + +def _remove_untrusted_image_references(text: str) -> str: + """没有可信原图列表时,删除模型输出的图片标签和裸图片路径。""" + if not text: + return text + + result = re.sub( + rf'!\[[^\]\n]*\]\([^\)\n]*\.{IMG_EXTENSIONS}(?:[?#][^\)\n]*)?\)', + '', + text, + flags=re.IGNORECASE, + ) + result = re.sub( + rf'(?)]+/images/[^\s<>)]+|/api/v1/knowledge/files/images/[^\s<>)]+|images/[^\s<>)]+|[A-Za-z0-9_-]+\.{IMG_EXTENSIONS})(?:[?#][^\s<>)]+)?', + '', + result, + flags=re.IGNORECASE, + ) + result = re.sub(r'[ \t]+\n', '\n', result) + result = re.sub(r'\n{3,}', '\n\n', result) + return result.strip() def find_closest_image_path(generated_path: str, original_paths: List[str]) -> str: @@ -37,7 +127,7 @@ def find_closest_image_path(generated_path: str, original_paths: List[str]) -> s best_score = score best_match = orig_path - if best_match and best_score > 0.5: + if best_match and _is_acceptable_image_match(generated_filename, best_match.split('/')[-1], best_score): return best_match return generated_path @@ -52,12 +142,15 @@ def normalize_markdown_images(text: str, base_prefix: str = "/api/v1/knowledge/f """ if not text or original_paths is None: return text + trusted_paths = [path for path in original_paths if path] + if not trusted_paths: + return _remove_untrusted_image_references(text) # ========== 第一轮:检查和修改格式(包括固定URL前缀) ========== result = _normalize_image_format(text, base_prefix) # ========== 第二轮:只检查和替换最后文件名部分 ========== - result = _normalize_image_paths(result, base_prefix, original_paths) + result = _normalize_image_paths(result, base_prefix, trusted_paths) return result @@ -71,10 +164,35 @@ def _normalize_image_format(text: str, base_prefix: str) -> str: if not text: return text - IMG_EXTENSIONS = r'(?:jpg|jpeg|png|gif|bmp|webp)' img_pattern = rf'([a-zA-Z0-9_\-./]+)\.({IMG_EXTENSIONS})' result = text + + def build_standard_tag(path: str) -> str: + full_url = _path_to_image_url(path, base_prefix) + return f'![图片]({full_url})' + + # 先处理已经是 Markdown 图片语法的内容,包含任意 alt 文本。 + # 例如:![图2-3 消磁电源柜功能框图](images/xxx.jpg) + markdown_img_pattern = rf'!\[[^\]\n]*\]\(([^)\n]*?\.{IMG_EXTENSIONS})\)' + result = re.sub( + markdown_img_pattern, + lambda m: build_standard_tag(m.group(1)), + result, + flags=re.IGNORECASE + ) + + # 再兜底清理模型或前序处理产生的嵌套图片语法。 + # 例如:![图2-3](![图片](/api/v1/knowledge/files/images/xxx.jpg) + nested_any_alt_pattern = rf'!\[[^\]\n]*\]\([^)\n]*!\[[^\]\n]*\]\(([^)\n]+\.{IMG_EXTENSIONS})\)\)?' + while re.search(nested_any_alt_pattern, result, re.IGNORECASE): + result = re.sub( + nested_any_alt_pattern, + lambda m: build_standard_tag(m.group(1)), + result, + flags=re.IGNORECASE + ) + matches = list(re.finditer(img_pattern, result, re.IGNORECASE)) if not matches: @@ -108,12 +226,8 @@ def _normalize_image_format(text: str, base_prefix: str) -> str: if '\n' not in middle and '![图片](' not in middle: mark_end = file_end + close_paren_pos + 1 - # 提取纯文件名(去掉可能的路径前缀) - pure_filename = filename_with_ext.split('/')[-1] - # 构建完整的标准格式(包括固定URL前缀) - full_url = base_prefix.rstrip('/') + '/images/' + pure_filename - standard_format = f'![图片]({full_url})' + standard_format = build_standard_tag(filename_with_ext) result = result[:mark_start] + standard_format + result[mark_end:] @@ -163,7 +277,7 @@ def _normalize_image_paths(text: str, base_prefix: str, original_paths: List[str best_match = None best_score = 0 - generated_filename = filename.lower() + generated_filename = _strip_url_suffix(filename).lower() for orig_path in original_paths: orig_filename = orig_path.split('/')[-1].lower() @@ -172,11 +286,10 @@ def _normalize_image_paths(text: str, base_prefix: str, original_paths: List[str best_score = score best_match = orig_path - if best_match and best_score > 0.5: + if best_match and _is_acceptable_image_match(generated_filename, best_match.split('/')[-1], best_score): # 匹配成功,替换为正确路径(保持固定URL前缀) # 只替换最后文件名部分,保持前缀不变 - pure_orig_filename = best_match.split('/')[-1] - full_url = base_prefix.rstrip('/') + '/images/' + pure_orig_filename + full_url = _path_to_image_url(best_match, base_prefix) standard_format = f'![图片]({full_url})' result = result[:tag_start] + standard_format + result[tag_end:] @@ -190,12 +303,10 @@ def _normalize_image_paths(text: str, base_prefix: str, original_paths: List[str def get_image_prompt_guidance() -> str: """获取图片格式处理的提示词指导""" return """【图片格式特别要求】 -- 检索结果中的图片链接格式通常为:`![图片](/api/v1/knowledge/files/images/xxx.jpg)` -- 输出时必须严格保留此格式,不得修改、省略或截断 -- 图片的 alt 文本必须保持为「图片」 -- 图片 URL 必须完整包含 `/api/v1/knowledge/files/images` 前缀 -- 不要将图片链接转换为纯文本描述 -- 如果有多个图片,在对应位置分别引用,保持原始顺序 -- 严禁修改图片文件名或路径结构 -- 如果需要展示图片,直接使用原始的 `![图片](URL)` 格式即可""" +- 只允许引用本次参考资料中真实出现的图片链接 +- 输出图片时使用 Markdown 图片标签:alt 文本固定为「图片」,括号内必须是参考资料里的原始完整 URL +- 图片文件名、路径和 URL 前缀必须逐字复制,不得补全、改写、截断、转义或根据记忆/示例构造 +- 不要输出提示词中的占位符或示例文件名,不要把普通文字描述改写成图片链接 +- 图片规则只用于生成图片标签,不要围绕图片资源状态输出任何文字说明 +- 多个图片可在对应位置分别引用并保持原始顺序;同一图片只引用一次"""