"""图片处理工具模块。""" import re 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]: """从文本中提取图片路径或 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: """ 找到与生成路径最接近的原始图片路径 即使模型输出的是错误/近似/截断的文件名,也能匹配到正确的原始路径 """ if not original_paths: return generated_path generated_filename = generated_path.split('/')[-1].lower() best_match = None best_score = 0 for orig_path in original_paths: orig_filename = orig_path.split('/')[-1].lower() score = SequenceMatcher(None, generated_filename, orig_filename).ratio() if score > best_score: best_score = score best_match = orig_path if best_match and _is_acceptable_image_match(generated_filename, best_match.split('/')[-1], best_score): return best_match return generated_path def normalize_markdown_images(text: str, base_prefix: str = "/api/v1/knowledge/files/", original_paths: List[str] = None) -> str: """ 规范化 Markdown 图片链接 - 两轮处理版 算法: 第一轮:检查和修改格式,确保是 ![图片](/api/v1/knowledge/files/images/xxx.jpg) 格式 第二轮:只检查和替换最后文件名部分(xxx.jpg),与原始路径匹配,匹配度低的删除 """ 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, trusted_paths) return result def _normalize_image_format(text: str, base_prefix: str) -> str: """ 第一轮:规范化图片格式,确保所有图片都是正确的 ![图片](/api/v1/knowledge/files/images/xxx.jpg) 格式 处理各种格式破损的情况,并确保有正确的URL前缀 """ if not text: return text 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: return result # 从后往前处理,避免位置偏移 for match in reversed(matches): filename_with_ext = match.group(0) file_start = match.start() file_end = match.end() mark_start = file_start mark_end = file_end # 向前查找是否已经有 ![图片]( 标记 lookback_limit = max(0, file_start - 100) before = result[lookback_limit:file_start] open_marker_pos = before.rfind('![图片](') if open_marker_pos != -1: actual_open_pos = lookback_limit + open_marker_pos check_segment = result[actual_open_pos:file_start] if check_segment.startswith('![图片]('): mark_start = actual_open_pos # 向后查找是否有 ) after = result[file_end:] close_paren_pos = after.find(')') if close_paren_pos != -1 and close_paren_pos < 20: middle = after[:close_paren_pos] if '\n' not in middle and '![图片](' not in middle: mark_end = file_end + close_paren_pos + 1 # 构建完整的标准格式(包括固定URL前缀) standard_format = build_standard_tag(filename_with_ext) result = result[:mark_start] + standard_format + result[mark_end:] # 清理嵌套格式 nested_pattern = r'!\[图片\]\([^)]*!\[图片\]\(([^)]+\.(?:jpg|jpeg|png|gif|bmp|webp))\)[^)]*\)' while re.search(nested_pattern, result, re.IGNORECASE): result = re.sub(nested_pattern, r'![图片](\1)', result, flags=re.IGNORECASE) return result def _normalize_image_paths(text: str, base_prefix: str, original_paths: List[str]) -> str: """ 第二轮:只规范化最后文件名部分(xxx.jpg),与原始路径匹配,匹配度低的删除 步骤: 1. 找到所有 ![图片](URL) 格式的图片 2. 对每个图片: a. 提取最后文件名部分(xxx.jpg) b. 只与 original_paths 中的最后文件名部分匹配 c. 如果匹配度高(>0.5),替换为正确路径 d. 如果匹配度低(<=0.5),删除整个图片 """ if not text or not original_paths: return text # 匹配所有 ![图片](...) 格式 img_tag_pattern = r'!\[图片\]\(([^)]+)\)' matches = list(re.finditer(img_tag_pattern, text, re.IGNORECASE)) if not matches: return text result = text # 从后往前处理,避免位置偏移 for match in reversed(matches): full_tag = match.group(0) url = match.group(1) tag_start = match.start() tag_end = match.end() # 从 URL 中只提取最后的文件名部分(xxx.jpg) filename = url.split('/')[-1] # 尝试匹配 - 只匹配最后文件名部分 best_match = None best_score = 0 generated_filename = _strip_url_suffix(filename).lower() for orig_path in original_paths: orig_filename = orig_path.split('/')[-1].lower() score = SequenceMatcher(None, generated_filename, orig_filename).ratio() if score > best_score: best_score = score best_match = orig_path if best_match and _is_acceptable_image_match(generated_filename, best_match.split('/')[-1], best_score): # 匹配成功,替换为正确路径(保持固定URL前缀) # 只替换最后文件名部分,保持前缀不变 full_url = _path_to_image_url(best_match, base_prefix) standard_format = f'![图片]({full_url})' result = result[:tag_start] + standard_format + result[tag_end:] else: # 匹配度低,删除整个图片 result = result[:tag_start] + result[tag_end:] return result def get_image_prompt_guidance() -> str: """获取图片格式处理的提示词指导""" return """【图片格式特别要求】 - 只允许引用本次参考资料中真实出现的图片链接 - 输出图片时使用 Markdown 图片标签:alt 文本固定为「图片」,括号内必须是参考资料里的原始完整 URL - 图片文件名、路径和 URL 前缀必须逐字复制,不得补全、改写、截断、转义或根据记忆/示例构造 - 不要输出提示词中的占位符或示例文件名,不要把普通文字描述改写成图片链接 - 图片规则只用于生成图片标签,不要围绕图片资源状态输出任何文字说明 - 多个图片可在对应位置分别引用并保持原始顺序;同一图片只引用一次"""