diff --git a/chunk_text.py b/chunk_text.py index 1b9a366..a237670 100644 --- a/chunk_text.py +++ b/chunk_text.py @@ -8,19 +8,457 @@ import re from typing import List, Dict, Optional, Union, Any, Tuple # 指定 JSON 文件路径 # file_path = '/storage01/home/hdf/project/wxkgrag/kgextract/output/122-06A0014-B01001_发动机-维修手册_content_list.json' -from pathlib import PurePath, Path as PathLib +try: + from filename_proceess_and_kgquery import get_entity +except ImportError: + get_entity = None +from pathlib import PurePath, PurePosixPath, Path as PathLib from fastapi import FastAPI, File, Path, UploadFile, HTTPException, Form, Request, Header, Body from fileparse_util import process_document +from io import BytesIO +from urllib.parse import unquote, urlparse +import asyncio +import base64 +import binascii import threading import aiofiles import httpx import logging -from config import API_URLS +from resetlevel import reset_textlevel +from rapidocr_onnxruntime import RapidOCR +from PIL import Image, ImageOps, UnidentifiedImageError +from config import ( + API_URLS, + IMAGE_DIR, + MAX_SPLIT_IMAGE_BASE64_CHARS, + MAX_SPLIT_IMAGE_BYTES, + MAX_SPLIT_IMAGE_INPUT_BYTES, + OCR_CONCURRENCY, + SPLIT_IMAGE_COMPRESS_THRESHOLD_BYTES, + SPLIT_IMAGE_JPEG_QUALITY, + SPLIT_IMAGE_MAX_DIMENSION, +) +try: + from config import SHIP_MODEL_NAME +except ImportError: + SHIP_MODEL_NAME = "" logger = logging.getLogger(__name__) +ocr_engine = RapidOCR() +OCR_SEMAPHORE = asyncio.Semaphore(max(1, OCR_CONCURRENCY)) +_ocr_thread_lock = threading.Lock() +_image_ocr_cache: Dict[str, str] = {} +_image_ocr_cache_lock = threading.Lock() _api_url_inflight = [0] * len(API_URLS) _api_url_lock = threading.Lock() +IMAGE_OCR_PREFIX = "图片中文本内容为:" +IMAGE_OCR_PREFIXES = (IMAGE_OCR_PREFIX, "图片包含的文字内容为:") + + +def _image_mime_type(suffix: str) -> str: + return { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".bmp": "image/bmp", + ".webp": "image/webp", + ".gif": "image/gif", + ".tif": "image/tiff", + ".tiff": "image/tiff", + }.get((suffix or "").lower(), "image/png") + + +def _image_save_format(suffix: str) -> tuple[str, dict]: + suffix = (suffix or "").lower() + if suffix in {".jpg", ".jpeg"}: + return "JPEG", {"quality": SPLIT_IMAGE_JPEG_QUALITY, "optimize": True} + if suffix == ".webp": + return "WEBP", {"quality": SPLIT_IMAGE_JPEG_QUALITY, "method": 6} + if suffix == ".png": + return "PNG", {"optimize": True, "compress_level": 9} + if suffix in {".tif", ".tiff"}: + return "TIFF", {"compression": "tiff_lzw"} + if suffix == ".gif": + return "GIF", {"optimize": True} + if suffix == ".bmp": + return "BMP", {} + return "PNG", {"optimize": True, "compress_level": 9} + + +def _compression_suffix(suffix: str) -> str: + suffix = (suffix or "").lower() + if suffix in {".jpg", ".jpeg", ".png", ".webp"}: + return suffix + if suffix in {".bmp", ".gif", ".tif", ".tiff"}: + return ".png" + return ".png" + + +def _save_image_to_bytes(img: Image.Image, suffix: str) -> bytes: + image_format, save_options = _image_save_format(suffix) + if image_format in {"JPEG", "WEBP", "BMP"} and img.mode not in {"RGB", "L"}: + img = img.convert("RGB") + elif image_format in {"PNG", "TIFF", "GIF"} and img.mode not in {"RGB", "RGBA", "L", "P"}: + img = img.convert("RGBA") + + output = BytesIO() + img.save(output, format=image_format, **save_options) + return output.getvalue() + + +def _save_image_as_jpeg(img: Image.Image) -> bytes: + if img.mode not in {"RGB", "L"}: + img = img.convert("RGB") + last_bytes = b"" + for quality in (SPLIT_IMAGE_JPEG_QUALITY, 82, 74): + output = BytesIO() + img.save(output, format="JPEG", quality=quality, optimize=True) + last_bytes = output.getvalue() + if len(last_bytes) <= MAX_SPLIT_IMAGE_BYTES: + break + return last_bytes + + +def compress_image_bytes(image_bytes: bytes, suffix: str = "") -> tuple[bytes, str]: + if not image_bytes: + raise HTTPException(status_code=400, detail="decoded image is empty") + if len(image_bytes) > MAX_SPLIT_IMAGE_INPUT_BYTES: + raise HTTPException(status_code=413, detail="image input is too large") + + suffix = (suffix or "").lower() + try: + with Image.open(BytesIO(image_bytes)) as img: + img = ImageOps.exif_transpose(img) + max_side = max(img.size) + should_resize = SPLIT_IMAGE_MAX_DIMENSION > 0 and max_side > SPLIT_IMAGE_MAX_DIMENSION + should_reencode = len(image_bytes) > SPLIT_IMAGE_COMPRESS_THRESHOLD_BYTES + + if not should_resize and not should_reencode: + if len(image_bytes) > MAX_SPLIT_IMAGE_BYTES: + raise HTTPException(status_code=413, detail="image is too large") + return image_bytes, suffix + + if should_resize: + scale = SPLIT_IMAGE_MAX_DIMENSION / max_side + new_size = ( + max(1, int(img.width * scale)), + max(1, int(img.height * scale)), + ) + resample = getattr(getattr(Image, "Resampling", Image), "LANCZOS") + img = img.resize(new_size, resample) + + compressed_suffix = _compression_suffix(suffix) + compressed_bytes = _save_image_to_bytes(img, compressed_suffix) + + if len(compressed_bytes) > MAX_SPLIT_IMAGE_BYTES: + compressed_bytes = _save_image_as_jpeg(img) + compressed_suffix = ".jpg" + except UnidentifiedImageError as exc: + raise HTTPException(status_code=400, detail="image content is not a supported image") from exc + except HTTPException: + raise + except Exception as exc: + logger.warning(f"Failed to compress image, using original bytes: {exc}") + compressed_bytes = image_bytes + compressed_suffix = suffix + + if len(compressed_bytes) >= len(image_bytes) and len(image_bytes) <= MAX_SPLIT_IMAGE_BYTES: + return image_bytes, suffix + if len(compressed_bytes) > MAX_SPLIT_IMAGE_BYTES: + raise HTTPException(status_code=413, detail="image is too large after compression") + return compressed_bytes, compressed_suffix + + +def prepare_split_image(image_base64: str, suffix: str = "") -> tuple[bytes, str]: + raw = (image_base64 or "").strip() + if not raw: + raise HTTPException(status_code=400, detail="image_base64 is empty") + + if "," in raw and raw.split(",", 1)[0].lower().startswith("data:image"): + raw = raw.split(",", 1)[1] + + raw = "".join(raw.split()) + if len(raw) > MAX_SPLIT_IMAGE_BASE64_CHARS: + raise HTTPException(status_code=413, detail="image_base64 is too large") + + try: + image_bytes = base64.b64decode(raw, validate=True) + except (binascii.Error, ValueError) as exc: + raise HTTPException(status_code=400, detail="image_base64 is not valid base64") from exc + + return compress_image_bytes(image_bytes, suffix) + + +def decode_base64_image(image_base64: str, suffix: str = "") -> bytes: + image_bytes, _ = prepare_split_image(image_base64, suffix) + return image_bytes + + +def safe_original_filename(filename: str) -> str: + name = os.path.basename((filename or "").strip()) + if not name: + raise HTTPException(status_code=400, detail="filename is empty") + safe_name = re.sub(r"[^\w.\-\u4e00-\u9fff]+", "_", name).strip("._") + if not safe_name: + raise HTTPException(status_code=400, detail="filename is invalid") + return safe_name + + +def _strip_markdown_image_path(img_path: str) -> str: + value = (img_path or "").strip() + match = re.match(r"!\[[^\]]*\]\((.*?)\)", value) + return match.group(1).strip() if match else value + + +def resolve_local_image_path(img_path: str) -> Optional[PathLib]: + value = _strip_markdown_image_path(str(img_path or "")) + if not value: + return None + + parsed_path = unquote(urlparse(value).path or value) + marker = "/images/" + if marker in parsed_path: + relative_path = parsed_path.split(marker, 1)[1] + else: + normalized = parsed_path.replace("\\", "/").lstrip("/") + if normalized.startswith("images/"): + relative_path = normalized.split("/", 1)[1] + else: + relative_path = normalized + + relative_path = relative_path.replace("\\", "/").lstrip("/") + if not relative_path: + return None + + image_root = PathLib(IMAGE_DIR).resolve() + local_path = (image_root / PurePosixPath(relative_path)).resolve() + try: + local_path.relative_to(image_root) + except ValueError: + logger.warning(f"Image path is outside IMAGE_DIR and will be ignored: {img_path}") + return None + + return local_path if local_path.is_file() else None + + +def extract_image_text_sync(image_path: Union[str, PathLib]) -> dict: + resolved_path = str(PathLib(image_path).resolve()) + with _image_ocr_cache_lock: + cached_text = _image_ocr_cache.get(resolved_path) + if cached_text is not None: + return {"texts": [], "full_text": cached_text, "elapse": None} + + with _ocr_thread_lock: + ocr_result, elapse = ocr_engine(resolved_path) + + texts = [item[1] for item in (ocr_result or [])] + full_text = IMAGE_OCR_PREFIX + ",".join(texts) if texts else "" + with _image_ocr_cache_lock: + _image_ocr_cache[resolved_path] = full_text + return { + "texts": texts, + "full_text": full_text, + "elapse": elapse, + } + + +def build_image_markdown_with_ocr(markdown_image: str, local_image_path: Optional[PathLib]) -> str: + if any(prefix in markdown_image for prefix in IMAGE_OCR_PREFIXES): + return markdown_image + if not local_image_path: + return markdown_image + + try: + ocr_data = extract_image_text_sync(local_image_path) + except Exception as exc: + logger.warning(f"Failed to OCR local image {local_image_path}: {exc}") + return markdown_image + + ocr_full_text = (ocr_data.get("full_text") or "").strip() + if not ocr_full_text: + return markdown_image + return f"{markdown_image}\n{ocr_full_text}" + + +async def get_image_ocr_text(local_image_path: Optional[PathLib]) -> str: + if not local_image_path: + return "" + + try: + ocr_data = await extract_image_text(local_image_path) + except Exception as exc: + logger.warning(f"Failed to OCR local image {local_image_path}: {exc}") + return "" + + return (ocr_data.get("full_text") or "").strip() + + +def stringify_content_field(value: Any) -> str: + if value is None: + return "" + if isinstance(value, list): + return "\n".join(str(item) for item in value if str(item).strip()) + if isinstance(value, dict): + return json.dumps(value, ensure_ascii=False) + return str(value) + + +def clean_table_body(table_body: Any) -> str: + table_body = stringify_content_field(table_body) + for pattern in [' colspan="1"', ' rowspan="1"', " colspan='1'", " rowspan='1'", " colspan=1", " rowspan=1"]: + table_body = table_body.replace(pattern, "") + return table_body + + +def join_nonempty_parts(*parts: Any) -> str: + return "\n".join(part for part in (stringify_content_field(part).strip() for part in parts) if part) + + +def record_to_chunk_text(ins: Dict[str, Any]) -> str: + record_type = ins.get("type") + ocr_text = ins.get("ocr_text", "") + + if record_type == "text": + text = stringify_content_field(ins.get("text", "")) + level = ins.get("text_level") + if level is not None and isinstance(level, int) and level > 0: + text = "#" * min(level, 6) + " " + text + return join_nonempty_parts(ocr_text, text) + + if record_type == "table": + return join_nonempty_parts( + ocr_text, + ins.get("table_caption"), + clean_table_body(ins.get("table_body", "")), + ins.get("table_footnote"), + ) + + if record_type == "image": + return join_nonempty_parts( + ocr_text, + ins.get("image_caption"), + ins.get("img_path"), + ins.get("image_footnote"), + ) + + if record_type == "equation": + return join_nonempty_parts( + ocr_text, + ins.get("text"), + ins.get("img_path"), + ) + + if record_type == "code": + return join_nonempty_parts( + ocr_text, + ins.get("code_caption"), + ins.get("code_body"), + ins.get("code_footnote"), + ) + + if record_type == "list": + return join_nonempty_parts(ocr_text, ins.get("list_items")) + + if record_type in {"discarded", "header", "footer", "page_number"}: + return join_nonempty_parts(ocr_text, ins.get("text")) + + return join_nonempty_parts(ocr_text, ins.get("text")) + + +def is_supported_chunk_record(ins: Dict[str, Any]) -> bool: + return ins.get("type") in { + "text", + "table", + "image", + "equation", + "code", + "list", + "discarded", + "header", + "footer", + "page_number", + } + + +def append_caption_ocr(ins: Dict[str, Any], ocr_texts: List[str]) -> None: + caption_field = { + "image": "image_caption", + "table": "table_caption", + "code": "code_caption", + }.get(ins.get("type")) + if not caption_field: + caption_field = "ocr_text" + + caption = ins.get(caption_field, []) + existing_text = stringify_content_field(caption) + if caption_field == "ocr_text": + existing_text = join_nonempty_parts(existing_text, ins.get("text")) + + additions = [] + for text in ocr_texts: + text = (text or "").strip() + text_without_prefix = text + for prefix in IMAGE_OCR_PREFIXES: + if text_without_prefix.startswith(prefix): + text_without_prefix = text_without_prefix[len(prefix):].strip() + break + + if ( + text + and text not in existing_text + and text_without_prefix not in existing_text + and text not in additions + ): + additions.append(text) + + if not additions: + return + + if caption_field == "ocr_text": + ins[caption_field] = join_nonempty_parts(additions, caption) + return + + if isinstance(caption, list): + ins[caption_field] = additions + caption + elif caption is None or not str(caption).strip(): + ins[caption_field] = additions + else: + ins[caption_field] = "\n".join(additions) + "\n" + str(caption).lstrip() + + +def build_image_markdown(markdown_or_path: str, prefix: str) -> Tuple[str, Optional[PathLib]]: + img_path = str(markdown_or_path).strip() + if not img_path: + return "", None + + if img_path.strip().startswith("!["): + return img_path, resolve_local_image_path(img_path) + + p = PurePosixPath(img_path) + if p.parts and p.parts[0] == "images": + relative_path = str(PurePosixPath(*p.parts[1:])) + return f"![图片]({prefix}{relative_path})", resolve_local_image_path(relative_path) + + return f"![图片]({prefix}{img_path})", resolve_local_image_path(img_path) + + +def image_base64_to_data_url(image_base64: Union[str, bytes], suffix: str) -> str: + if isinstance(image_base64, bytes): + raw = base64.b64encode(image_base64).decode("ascii") + else: + raw = (image_base64 or "").strip() + if "," in raw and raw.split(",", 1)[0].lower().startswith("data:image"): + raw = raw.split(",", 1)[1] + raw = "".join(raw.split()) + + mime_type = _image_mime_type(suffix) + return f"data:{mime_type};base64,{raw}" + + +async def extract_image_text(image_path: PathLib) -> dict: + async with OCR_SEMAPHORE: + return await asyncio.to_thread(extract_image_text_sync, image_path) def format_entity_text(data): # 1. 定义实体类型与属性的映射关系 @@ -329,75 +767,13 @@ def split_content_bbox(data, max_length=8000): bbox = ins.get("bbox") page_idx = ins.get("page_idx", -1) - new_text = "" - should_record_highlight = False # 标记是否需要记录 highlight(即使 new_text 为空) + if not is_supported_chunk_record(ins): + continue - if ins["type"] == "text": - text = ins.get("text", "") - level = ins.get("text_level") - if level is not None and isinstance(level, int) and level > 0: - text = "#" * min(level, 6) + " " + text # 安全限制标题级别 - new_text = text + "\n" - should_record_highlight = True - - elif ins["type"] == "table": - table_caption = ins.get("table_caption") - table_body = ins.get("table_body", "") - - # 清理冗余属性 - for pattern in [' colspan="1"', ' rowspan="1"', " colspan='1'", " rowspan='1'", " colspan=1", " rowspan=1"]: - table_body = table_body.replace(pattern, "") - - caption_str = "" - if table_caption: - if isinstance(table_caption, list): - caption_str = "\n".join(str(c) for c in table_caption) - else: - caption_str = str(table_caption) - - # 判断是否有实质内容 - has_caption = bool(caption_str.strip()) - has_body = bool(table_body.strip()) - - if has_caption or has_body: - new_text = caption_str + ("\n" if has_caption else "") + table_body + "\n" - else: - new_text = "" # 空内容,不拼接 - - # 即使内容为空,只要是 table 类型,就记录 highlight(满足你的需求) - should_record_highlight = True - - elif ins["type"] == "image": - image_caption = ins.get("image_caption", "") - img_path = ins.get("img_path", "") - - if isinstance(image_caption, list): - image_caption = "\n".join(str(item) for item in image_caption) - elif not isinstance(image_caption, str): - image_caption = str(image_caption) - - if isinstance(img_path, list): - img_path = "\n".join(str(p) for p in img_path) - elif not isinstance(img_path, str): - img_path = str(img_path) - - if image_caption.strip() or img_path.strip(): - new_text = image_caption + "\n" + img_path + "\n" - else: - new_text = "" - - should_record_highlight = True - - elif ins["type"] == "discarded": - text = ins.get("text", "") - if text.strip(): - new_text = text + "\n" - else: - new_text = "" - should_record_highlight = True - - else: - continue # 不支持的 type,跳过且不记录 highlight + new_text = record_to_chunk_text(ins) + if new_text.strip(): + new_text += "\n" + should_record_highlight = True # --- 关键:即使 new_text 为空,只要 should_record_highlight 为 True,就要处理 chunk 切分和 highlight 记录 --- @@ -434,31 +810,89 @@ def split_content_bbox(data, max_length=8000): +# def group_records(records): +# result = [] +# current_group = [] + +# for i, ins in enumerate(records): +# if 'text_level' in ins and ins['text_level']: +# # 当遇到新的text_level时,如果current_group非空,则先将其添加到结果中 +# if current_group: +# result.append(current_group) +# current_group = [] # 开始新一组 +# current_group.append(ins) +# else: +# # 如果当前记录没有text_level或其值为空,则继续添加到当前组 +# current_group.append(ins) + +# # 别忘了将最后一个组添加到结果中 +# if current_group: +# result.append(current_group) + +# return result + def group_records(records): + """ + 按 text_level 分组,并为每个组合适的父级标题上下文。 + + 规则: + - text_level=N (N>1) 的组 → 在组开头注入最近的 text_level=1 到 N-1 的标题 + """ result = [] current_group = [] - + + # 维护标题栈:level -> {text, page_idx, bbox} + heading_stack = {} + for i, ins in enumerate(records): - if 'text_level' in ins and ins['text_level']: - # 当遇到新的text_level时,如果current_group非空,则先将其添加到结果中 + text_level = ins.get('text_level') + + if text_level is not None and isinstance(text_level, int) and text_level > 0: + # 更新标题栈 + heading_stack[text_level] = { + 'text': ins.get('text', ''), + 'page_idx': ins.get('page_idx', -1), + 'bbox': ins.get('bbox', []), + 'level': text_level + } + # 清除更低层级的标题(高层级变化后,低层级失效) + levels_to_remove = [l for l in list(heading_stack.keys()) if l > text_level] + for l in levels_to_remove: + del heading_stack[l] + + # 遇到新标题,先保存当前组 if current_group: result.append(current_group) - current_group = [] # 开始新一组 - current_group.append(ins) + current_group = [] + + # 注入父级标题(如果当前层级 > 1) + if text_level > 1: + prefix_records = [] + for level in sorted(heading_stack.keys()): + if level < text_level: + h = heading_stack[level] + prefix_records.append({ + 'type': 'text', + 'text': h['text'], + 'text_level': level, + 'page_idx': h['page_idx'], + 'bbox': h['bbox'] + }) + current_group = prefix_records + [ins] + else: + current_group = [ins] else: - # 如果当前记录没有text_level或其值为空,则继续添加到当前组 + # 非标题记录,继续添加到当前组 current_group.append(ins) - # 别忘了将最后一个组添加到结果中 if current_group: result.append(current_group) return result - def get_chunk_bbox_otherfile(data, max_length=8000): """ - 处理没有 bbox 信息的文件数据(主要面向纯表格类内容)。 - 每个 table 作为一个独立切片,不与其他记录拼接。 + 处理没有 bbox 信息的文件数据。 + 每条支持的记录作为一个独立切片,不与其他记录拼接。 - 单个 table 内容超过 max_length 时,调用 chunk_reset 在 处安全切分, 切分后的多个 chunk 共享同一组 positions @@ -470,38 +904,14 @@ def get_chunk_bbox_otherfile(data, max_length=8000): slices = [] for ins in data: - if ins.get("type") != "table": + if not is_supported_chunk_record(ins): continue page_idx = ins.get("page_idx", -1) bbox = ins.get("bbox", []) - - table_caption = ins.get("table_caption") - table_body = ins.get("table_body", "") or "" - - # 清理冗余属性 - for pattern in [ - ' colspan="1"', ' rowspan="1"', - " colspan='1'", " rowspan='1'", - " colspan=1", " rowspan=1", - ]: - table_body = table_body.replace(pattern, "") - - # 拼接 caption - caption_str = "" - if table_caption: - if isinstance(table_caption, list): - caption_str = "\n".join(str(c) for c in table_caption) - else: - caption_str = str(table_caption) - - has_caption = bool(caption_str.strip()) - has_body = bool(table_body.strip()) - - if not (has_caption or has_body): - continue # 空表格跳过 - - content = caption_str + ("\n" if has_caption else "") + table_body + content = record_to_chunk_text(ins) + if not content.strip(): + continue positions = [{"page_idx": page_idx, "bbox": bbox}] # 单表超长 → 在 处切分,多个 chunk 共享 positions @@ -602,38 +1012,11 @@ def get_chunk_bbox(data): bbox = ins["bbox"] positions = [{"page_idx": page_idx, "bbox": bbox}] - if ins["type"] == "text": - if ins.get("text_level"): - text = ins.get("text", "") - level = ins.get("text_level") - content = "#" * level + text - else: - content = ins.get("text", "") - elif ins["type"] == "table": - content = (ins.get("table_caption") or "") + "\n" + (ins.get("table_body") or "") - elif ins["type"] == "image": - image_caption = ins.get("image_caption", []) - img_path = ins.get("img_path", []) + if not is_supported_chunk_record(ins): + continue - # 如果 image_caption 是列表,则将其转换为字符串 - if isinstance(image_caption, list): - image_caption = "\n".join(str(item) for item in image_caption) - elif not isinstance(image_caption, str): - image_caption = str(image_caption) - - # 如果 img_path 是列表,则将其转换为字符串 - if isinstance(img_path, list): - img_path = "\n".join(str(p) for p in img_path) - elif not isinstance(img_path, str): - img_path = str(img_path) - - if image_caption.strip() or img_path.strip(): - content = (image_caption or "") + "\n" + (img_path or "") - else: - continue # 跳过空图像项 - elif ins["type"] == "discarded": - content = ins.get("text", "") - else: + content = record_to_chunk_text(ins) + if not content.strip(): continue slices.append({"content": content, "positions": positions}) @@ -704,23 +1087,35 @@ def split_text_preserve_sentences(text, max_length=7500): return chunks -def data_replace(data, prefix): +async def data_replace(data, prefix): """ 遍历 data 列表,若元素包含 'img_path' 字段,则移除前缀 'images/' 并拼接新前缀。 使用 pathlib 安全处理路径。 """ - from pathlib import PurePosixPath for ins in data: if isinstance(ins, dict) and "img_path" in ins: img_path = ins["img_path"] - p = PurePosixPath(img_path) - # 如果以 images/ 开头,去掉第一级目录 - if p.parts and p.parts[0] == "images": - relative_path = str(PurePosixPath(*p.parts[1:])) - ins["img_path"] = f"![图片]({prefix}{relative_path})" + local_image_paths = [] + if isinstance(img_path, list): + markdown_images = [] + for item in img_path: + if not str(item).strip(): + continue + markdown_image, local_image_path = build_image_markdown(str(item), prefix) + markdown_images.append(markdown_image) + if local_image_path: + local_image_paths.append(local_image_path) + ins["img_path"] = "\n".join(markdown_images) else: - # 否则保留原路径(或按需处理) - ins["img_path"] = f"![图片]({prefix}{img_path})" + markdown_image, local_image_path = build_image_markdown(str(img_path), prefix) + ins["img_path"] = markdown_image + if local_image_path: + local_image_paths.append(local_image_path) + + ocr_texts = await asyncio.gather( + *(get_image_ocr_text(path) for path in local_image_paths) + ) + append_caption_ocr(ins, ocr_texts) return data def filter_content_bbox(data, max_length=3000): @@ -744,45 +1139,15 @@ def filter_content_bbox(data, max_length=3000): if "type" not in ins: continue - added_content = "" bbox = ins.get("bbox", []) page_idx = ins.get("page_idx", -1) - if ins["type"] == "text": - text_content = ins.get("text", "") - added_content = text_content + "\n" + if not is_supported_chunk_record(ins): + continue - elif ins["type"] == "table": - table_caption = ins.get("table_caption") - table_body = ins.get("table_body") - - caption_str = "" - if table_caption: - if isinstance(table_caption, list): - caption_str = "\n".join(str(c) for c in table_caption) - else: - caption_str = str(table_caption) - - added_content = caption_str + "\n" + (table_body or "") + "\n" - - elif ins["type"] == "image": - image_caption = ins.get("image_caption", "") - img_path = ins.get("img_path", "") - - if isinstance(image_caption, list): - image_caption = "\n".join(str(item) for item in image_caption) - elif not isinstance(image_caption, str): - image_caption = str(image_caption) - - if isinstance(img_path, list): - img_path = "\n".join(str(p) for p in img_path) - elif not isinstance(img_path, str): - img_path = str(img_path) - - if image_caption or img_path: - added_content = image_caption + "\n" + img_path + "\n" - else: - continue # 跳过空图像项 + added_content = record_to_chunk_text(ins) + if added_content.strip(): + added_content += "\n" else: continue @@ -897,7 +1262,7 @@ def find_ship_info_by_hull(json_file_path, data): except json.JSONDecodeError: print("错误:JSON 文件格式不正确") return None -def merge_short_slices(slices, min_length=30,filename="122-06A0014-B01003_雷达-使用说明书.pdf"): +# def merge_short_slices(slices, min_length=30,filename="122-06A0014-B01003_雷达-使用说明书.pdf"): """ 合并过短的切片: - 如果某切片 content 长度 <= min_length,则将其合并到下一个切片的开头 @@ -956,6 +1321,146 @@ def merge_short_slices(slices, min_length=30,filename="122-06A0014-B01003_雷达 "positions": pending_positions }) + try: + final_result = get_entity(filename) + xinghao = find_ship_info_by_hull(SHIP_MODEL_NAME, final_result) + if xinghao is None: # 确保 xinghao 为 None 时不会报错 + xinghao = {"model_name": "", "ship_name": ""} + print("未找到匹配的舰船信息") + + # 安全处理 xinghao 为 None 的情况 + model_name = "" + if xinghao and 'model_name' in xinghao: + model_name = xinghao['model_name'] + + other_data = format_entity_text(final_result) + logger.info(f"文件名实体提取成功: {other_data}") + except Exception as e: + logger.warning(f"文件名实体提取失败: {e}") + other_data = "" + model_name = "" # 确保异常时 model_name 有定义 + + # 将提取的信息(如舰艇名、型号)注入到每个切片的开头 + for ins in result: + content = ins['content'] + # 如果没有提取到有效数据,则跳过注入 + + newline_idx = content.find('\n') + info = other_data + if model_name: + info += f',型号为{model_name}' + suffix = f'({info})' + + # 将信息插入到第一行末尾 + if newline_idx == -1: + ins['content'] = content + suffix + else: + ins['content'] = content[:newline_idx] + suffix + content[newline_idx:] + + return result + +def merge_short_slices(slices, min_length=30, filename="122-06A0014-B01003_雷达-使用说明书.pdf"): + """ + 合并过短的切片: + - 如果某切片 content 长度 <= min_length,则将其合并到下一个切片的开头 + - 若处于末尾无下一个切片,则反向合并到上一个切片末尾 + - content 用换行拼接,positions 顺序拼接 + + Args: + slices: [{"content": str, "positions": [...]}] + min_length: 短切片的字符长度阈值(含) + filename: 用于实体提取的文件名 + + Returns: + 合并后的 slices 列表 + """ + if not slices: + return slices + + result = [] + pending_contents = [] # 缓存等待合并到"下一个"的短切片 content + pending_positions = [] # 缓存对应的 positions + + for ins in slices: + content = ins.get("content", "") or "" + positions = ins.get("positions", []) or [] + + if len(content) <= min_length: + # 暂存,等到下一个正常长度的切片再合并 + pending_contents.append(content) + pending_positions.extend(positions) + else: + # 正常切片:把暂存的短切片合并到它的开头 + if pending_contents: + merged_prefix = "\n".join(pending_contents) + content = merged_prefix + ("\n" if merged_prefix else "") + content + positions = pending_positions + positions + pending_contents = [] + pending_positions = [] + + result.append({ + "content": content, + "positions": positions + }) + + # 收尾:如果末尾还有未合并的短切片(后面没有正常切片可合并) + # 则反向合并到上一个切片末尾 + if pending_contents: + merged_suffix = "\n".join(pending_contents) + if result: + last = result[-1] + last["content"] = (last["content"] or "") + ("\n" if last["content"] else "") + merged_suffix + last["positions"] = (last["positions"] or []) + pending_positions + else: + # 极端情况:所有切片都很短,整体作为一个切片返回 + result.append({ + "content": merged_suffix, + "positions": pending_positions + }) + + try: + final_result = get_entity(filename) + xinghao = find_ship_info_by_hull(SHIP_MODEL_NAME, final_result) + if xinghao is None: # 确保 xinghao 为 None 时不会报错 + xinghao = {"model_name": "", "ship_name": ""} + print("未找到匹配的舰船信息") + + # 安全处理 xinghao 为 None 的情况 + model_name = "" + if xinghao and 'model_name' in xinghao: + model_name = xinghao['model_name'] + + other_data = format_entity_text(final_result) + logger.info(f"文件名实体提取成功: {other_data}") + except Exception as e: + logger.warning(f"文件名实体提取失败: {e}") + other_data = "" + model_name = "" # 确保异常时 model_name 有定义 + + # 将提取的信息(如舰艇名、型号)注入到每个切片的开头 + for ins in result: + content = ins['content'] + # 如果没有提取到有效数据,则跳过注入 + + newline_idx = content.find('\n') + info = other_data + if model_name: + info += f',型号为{model_name}' + + # ===== 修改部分:拼接文件名 + 实体信息 ===== + # 构建前缀:#文件名为:xxx.pdf(实体信息) + filename_prefix = f"#文件名为:{filename}" + if info: + filename_prefix += f"({info})" + # 注意:这里使用全角右括号")"以匹配你的示例,如需半角请改为 ")" + + # 将前缀插入到 content 的最开头 + if content: + ins['content'] = filename_prefix + "\n" + content + else: + ins['content'] = filename_prefix + # ========================================== + return result def find_and_read_content_list(directory, original_filename, encoding='utf-8'): """ @@ -993,13 +1498,13 @@ async def process_pdf_file(pdf_path: Path, image_prefix: str,filename:str) -> Op if doc_result is not None: content_list, images = doc_result if content_list and images: - replaced_content_list = data_replace(content_list, image_prefix) + replaced_content_textlevel = reset_textlevel(content_list) + replaced_content_list = await data_replace(replaced_content_textlevel, image_prefix) slices = get_chunk_bbox(replaced_content_list) slices_check = chunk_check(slices, 8000) slices_check = merge_short_slices(slices_check, min_length=30,filename=filename) return {"slices": slices_check, "images": images} - else: - return {"slices": [], "images": {}} + api_url = get_api_url() try: async with aiofiles.open(pdf_path, "rb") as f: @@ -1017,12 +1522,12 @@ async def process_pdf_file(pdf_path: Path, image_prefix: str,filename:str) -> Op data = result.get("data", {}) content_list = data.get("content_list", []) images = data.get("images", {}) - replaced_content_list = data_replace(content_list, image_prefix) + replaced_content_textlevel = reset_textlevel(content_list) + replaced_content_list = await data_replace(replaced_content_textlevel, image_prefix) slices = get_chunk_bbox(replaced_content_list) slices_check = chunk_check(slices, 8000) slices_check = merge_short_slices(slices_check, min_length=30,filename=filename) - - return {"slices": [], "images": {}} + return {"slices": slices_check, "images": images} except json.JSONDecodeError: logger.error("❌ 响应不是有效的 JSON 格式") @@ -1041,16 +1546,57 @@ async def process_other_file(pdf_path: Path, image_prefix: str, filename: str) - if doc_result is not None: content_list, images = doc_result if content_list and images: - replaced_content_list = data_replace(content_list, image_prefix) + replaced_content_textlevel = reset_textlevel(content_list) + replaced_content_list = await data_replace(replaced_content_textlevel, image_prefix) slices = get_chunk_bbox(replaced_content_list) slices_check = chunk_check(slices, 8000) - slices_check = merge_short_slices(slices_check, min_length=30,filename=filename) return {"slices": slices_check, "images": images} - else: + + api_url = get_api_url() + analyze_other_url = api_url.replace("/analyze-pdf", "/analyze-otherfile") + + mime_type, _ = mimetypes.guess_type(pdf_path.name) + content_type = mime_type or "application/octet-stream" + + try: + async with aiofiles.open(pdf_path, "rb") as f: + file_content = await f.read() + + files = {"file": (pdf_path.name, file_content, content_type)} + + async with httpx.AsyncClient(timeout=60 * 20) as client: + response = await client.post(analyze_other_url, files=files) + + if response.status_code != 200: + logger.error(f"❌ Office 文件 API 请求失败,状态码: {response.status_code}, 响应: {response.text}") + return None + + result = response.json() + data = result.get("data", {}) + content_list = data.get("content_list", []) + images = data.get("images", {}) + + # 后续的数据替换与切片处理逻辑 + replaced_content_textlevel = reset_textlevel(content_list) + replaced_content_list = await data_replace(replaced_content_textlevel, image_prefix) + slices = get_chunk_bbox(replaced_content_list) + slices_check = chunk_check(slices, 8000) + + return {"slices": slices_check, "images": images} + + except json.JSONDecodeError: + error_text = response.text if 'response' in locals() else "请求未到达服务器" + logger.error(f"❌ 响应不是有效的 JSON 格式: {error_text}") return {"slices": [], "images": {}} + except Exception as e: + logger.error(f"处理 Office 文件时出错: {e}", exc_info=True) + return None + finally: + release_api_url(api_url) + if __name__ == "__main__": - filepath = r"E:\ZKYNLP\Hjunproject\project0506\kgrag\122-06A0014-B01001_发动机-维修手册_content_list.json" + filepath ="/app/mineru_output/舰船抗沉损管训练仿真系统研究_content_list.json" try: with open(filepath, 'r', encoding='utf-8') as file: data = json.load(file) @@ -1060,12 +1606,12 @@ if __name__ == "__main__": # print(len(data)) start_chars = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "第", "十", "一", "二", "三", "四", "五", "六", "七", "八", "九"] - - data = data_replace(data=data,prefix="/api/v1/knowledge/files/images/") + replaced_content_textlevel = reset_textlevel(data) + data = asyncio.run(data_replace(data=replaced_content_textlevel,prefix="/api/v1/knowledge/files/images/")) slices = get_chunk_bbox(data=data) slices_1 =chunk_check(slices, 8000) slices_2 = merge_short_slices(slices_1, min_length=30) # ← 新增这一行 - for ins in slices_2: + for ins in slices_2[:30]: print(ins) diff --git a/resetlevel.py b/resetlevel.py new file mode 100644 index 0000000..cae52e7 --- /dev/null +++ b/resetlevel.py @@ -0,0 +1,184 @@ +import copy +import json +import re +import sys +from typing import Any + + +CN_NUM = "\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u3007\u96f6\u4e24" + +CHAPTER_PATTERN = re.compile(rf"^\s*\u7b2c\s*([{CN_NUM}\d]+)\s*\u7ae0") +CN_LEVEL1_PATTERN = re.compile(rf"^\s*([{CN_NUM}]+)(?=\s|[\u3001\u3002\uff0e.])") + +CASE_LIKE_PATTERN = re.compile(r"^\s*\d+(?:\.|\uff0e)(?!\d|\s|$)") +ARABIC_PATTERN = re.compile( + r"^\s*(\d+(?:\.\d+)*)(?=\s|[\u4e00-\u9fff\u3001\u3002\uff0e]|$|\.(?:\s|$))" +) + + +def has_valid_text_level(item: dict) -> bool: + return "text_level" in item and item.get("text_level") not in (None, "") + + +def parse_heading(text: str): + text = text.strip() + if not text: + return None, None + + if CASE_LIKE_PATTERN.match(text): + return "case_like", None + + if CHAPTER_PATTERN.match(text): + return "level1", None + + if CN_LEVEL1_PATTERN.match(text): + return "level1", None + + match = ARABIC_PATTERN.match(text) + if match: + return "arabic", match.group(1) + + return None, None + + +def arabic_depth(number: str) -> int: + return number.count(".") + 1 + + +def parent_number(number: str) -> str | None: + parts = number.split(".") + if len(parts) <= 1: + return None + return ".".join(parts[:-1]) + + +def collect_headings(data: list) -> list: + headings = [] + + for item in data: + if not isinstance(item, dict): + continue + if item.get("type") != "text": + continue + if not has_valid_text_level(item): + continue + + kind, number = parse_heading(item.get("text", "")) + if kind in {"level1", "arabic", "case_like"}: + headings.append({"kind": kind, "number": number}) + + return headings + + +def is_valid_arabic_hierarchy(numbers: list[str], allow_missing_depth1_parent: bool) -> bool: + seen = set() + has_depth2 = False + has_depth3 = False + + for number in numbers: + depth = arabic_depth(number) + if depth >= 2: + has_depth2 = True + if depth >= 3: + has_depth3 = True + + parent = parent_number(number) + if parent is not None: + parent_depth = arabic_depth(parent) + parent_is_missing_depth1 = allow_missing_depth1_parent and parent_depth == 1 + if parent not in seen and not parent_is_missing_depth1: + return False + + seen.add(number) + + return has_depth2 and has_depth3 + + +def detect_valid_mode(headings: list): + if not headings: + return None + + if any(heading["kind"] == "case_like" for heading in headings): + return None + + first = headings[0] + + if first["kind"] == "arabic": + numbers = [heading["number"] for heading in headings if heading["kind"] == "arabic"] + if numbers and arabic_depth(numbers[0]) == 1: + if is_valid_arabic_hierarchy(numbers, allow_missing_depth1_parent=False): + return "A" + return None + + if first["kind"] == "level1": + numbers = [heading["number"] for heading in headings[1:] if heading["kind"] == "arabic"] + if not numbers: + return None + + first_depth = arabic_depth(numbers[0]) + if first_depth == 2 and is_valid_arabic_hierarchy(numbers, allow_missing_depth1_parent=True): + return "B" + + if first_depth == 1 and is_valid_arabic_hierarchy(numbers, allow_missing_depth1_parent=False): + return "C" + + return None + + +JsonContent = Any + + +def reset_textlevel(json_content: JsonContent) -> JsonContent: + """ + 输入 JSON 内容,返回更新 text_level 后的新 JSON 内容。 + + json_content 可以是已经 json.load/json.loads 后的 Python 对象, + 也可以是 JSON 字符串。函数不会修改原始入参。 + """ + if isinstance(json_content, str): + data = json.loads(json_content) + else: + data = copy.deepcopy(json_content) + + if not isinstance(data, list): + return data + + headings = collect_headings(data) + mode = detect_valid_mode(headings) + + if mode is None: + return data + + for item in data: + if not isinstance(item, dict): + continue + if item.get("type") != "text": + continue + if not has_valid_text_level(item): + continue + + kind, number = parse_heading(item.get("text", "")) + if kind == "level1": + item["text_level"] = 1 + continue + if kind != "arabic": + continue + + depth = arabic_depth(number) + item["text_level"] = depth if mode in {"A", "B"} else depth + 1 + + return data + + + + + +if __name__ == "__main__": + json_file_path = r'E:\ZKYNLP\Hjunproject\project0506\kgrag\163-06A0014-B01001_发动机-维修手册_content_list.json' + with open(json_file_path, "r", encoding="utf-8") as f: + json_content = json.load(f) + result= reset_textlevel(json_content) + + for ins in result[:30]: + print(ins) + # print(json.dumps(result, ensure_ascii=False, indent=2))