diff --git a/api/chat_routes.py b/api/chat_routes.py index a78e031..3373e1a 100644 --- a/api/chat_routes.py +++ b/api/chat_routes.py @@ -1373,12 +1373,20 @@ _FIGURE_ANSWER_KEYWORDS = frozenset([ def _replace_table_image_placeholders(answer: str, images: List[Dict]) -> str: """ - 将回答中 markdown 表格内的 [图片] 占位符替换为实际图片语法。 + 将回答中 markdown 表格内的图片占位符替换为实际图片语法。 - LLM 看到的上下文里表格单元格是 [图片](MinerU 解析产物),它也原样输出。 - 此函数按顺序将每个 [图片] 映射到 images 列表中对应图片的 URL, + LLM 看到的上下文里表格单元格图片以 [图片] 或 [N张图片](MinerU 解析产物)表示, + LLM 通常会原样输出,有时还会加上 ![图片](图片URL) 格式。 + + 此函数按顺序将占位符映射到 images 列表中的 URL, 生成 ![img](/images/xxx.png) 语法,前端 markdown-it 可直接渲染为 标签。 + 处理的占位符格式: + - [图片] → ![id](url) (单张) + - ![图片](图片URL) → ![id](url) (LLM 加了 ! 和假 URL) + - [3张图片] → ![id1](u1) ![id2](u2) ![id3](u3) + - ![3张图片](图片URL) → 同上 (LLM 加了前缀和假 URL) + Args: answer: LLM 生成的回答文本 images: select_images + _filter_images_by_answer 后的最终图片列表 @@ -1386,28 +1394,35 @@ def _replace_table_image_placeholders(answer: str, images: List[Dict]) -> str: Returns: 替换后的回答文本 """ - if not images or '[图片]' not in answer: + if not images or '图片' not in answer: return answer - # 按顺序替换:第 N 个 [图片] → 第 N 张图片 img_iter = iter(images) replaced_count = 0 def _replacer(match): nonlocal replaced_count - try: - img = next(img_iter) - url = img.get('url', '') - img_id = img.get('id', 'img') - replaced_count += 1 - return f'![{img_id}]({url})' - except StopIteration: - # 图片用完了,保留剩余占位符 - return match.group(0) + n_str = match.group(1) # 数字部分,None 表示单张 + n = int(n_str) if n_str else 1 - result = re.sub(r'\[图片\]', _replacer, answer) + parts = [] + for _ in range(n): + try: + img = next(img_iter) + url = img.get('url', '') + img_id = img.get('id', 'img') + parts.append(f'![{img_id}]({url})') + replaced_count += 1 + except StopIteration: + parts.append('[图片]') # 图片用完了 + return ' '.join(parts) + + # !? — 可选前导 !(LLM 有时加) + # \[(\d+)?张?图片\] — [图片] 或 [3张图片](张 可选) + # (?:\(图片URL\))? — 可选的 LLM 从上下文复制的假 URL + result = re.sub(r'!?\[(\d+)?张?图片\](?:\(图片URL\))?', _replacer, answer) if replaced_count > 0: - logger.info(f"[图片占位符替换] 替换了 {replaced_count}/{len(images)} 个 [图片] 为 markdown 图片语法") + logger.info(f"[图片占位符替换] 替换了 {replaced_count}/{len(images)} 个图片为 markdown 图片语法") return result diff --git a/knowledge/manager.py b/knowledge/manager.py index 66b2f2f..0fd8fe6 100644 --- a/knowledge/manager.py +++ b/knowledge/manager.py @@ -39,6 +39,7 @@ from pathlib import Path import logging import chromadb +from bs4 import BeautifulSoup # 从 base.py 导入基础类和常量 from .base import ( @@ -550,8 +551,39 @@ class KnowledgeBaseManager( curr_html = getattr(current, 'table_html', '') or '' next_html = getattr(next_chunk, 'table_html', '') or '' if curr_html and next_html: - # 合并两个表格的 HTML - current.table_html = curr_html + '\n' + next_html + # 正确合并两个表格的 HTML: + # 将第二个表格的 行追加到第一个表格中 + # (而非简单拼接两个 ,否则 html_table_to_markdown + # 的 soup.find('table') 只能找到第一个表格) + try: + soup1 = BeautifulSoup(curr_html, 'html.parser') + soup2 = BeautifulSoup(next_html, 'html.parser') + table1 = soup1.find('table') + table2 = soup2.find('table') + if table1 and table2: + # 从第二个表格提取数据行 + next_rows = table2.find_all('tr') + # 跳过与第一个表格表头重复的行 + # 对比第一行而非所有 th(find_all('th') 会匹配 + # 整个表格的 th,无法与单行做列表比较) + first_row_t1 = table1.find('tr') + if first_row_t1 and next_rows: + row1_texts = [c.get_text(strip=True) for c in first_row_t1.find_all(['th', 'td'])] + row2_texts = [c.get_text(strip=True) for c in next_rows[0].find_all(['th', 'td'])] + if row1_texts and row2_texts and row1_texts == row2_texts: + next_rows = next_rows[1:] + logger.debug("跨页表格合并: 跳过了重复的表头行") + for row in next_rows: + table1.append(row) + current.table_html = str(soup1) + logger.debug(f"跨页表格 HTML 合并成功: 追加了 {len(next_rows)} 行") + else: + current.table_html = curr_html + '\n' + next_html + except Exception as e: + logger.warning(f"跨页表格 HTML 合并异常: {e},回退到简单拼接") + current.table_html = curr_html + '\n' + next_html + elif not curr_html and next_html: + current.table_html = next_html # 合并 image_path 和嵌入图片到 images curr_img = getattr(current, 'image_path', None)