增加图片文本化接口、切片图片文本抽取‘

This commit is contained in:
Defeng 2026-07-17 15:13:24 +08:00
parent fdb6642b8b
commit 6294652de8
54 changed files with 14075 additions and 55 deletions

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

18
apitest.py Normal file
View File

@ -0,0 +1,18 @@
import base64
import requests
url = "http://192.168.0.46:59085/split_image"
image_path = r"E:\ZKYNLP\aidocproject\audiotransaction\gwdoc\船舶图片.png"
with open(image_path, "rb") as f:
image_base64 = base64.b64encode(f.read()).decode("utf-8")
payload = {
"content": "船舶主发动机为低速二冲程船用推进柴油机,是船舶推进系统的核心动力装置,其主要功能是将燃油化学能转化为机械能,通过曲轴输出转矩,驱动推进轴系和螺旋桨,实现船舶航行。![图片](/api/v1/knowledge/files/images/船舶图片.png)",
"filename": "船舶图片.png",
"image_base64": image_base64,
}
resp = requests.post(url, json=payload, timeout=120)
print(resp.status_code)
print(resp.json())

112
app.py
View File

@ -15,7 +15,7 @@ try:
except ImportError:
from langchain.prompts import FewShotPromptTemplate, PromptTemplate
from openai.types.chat import ChatCompletionSystemMessageParam, ChatCompletionUserMessageParam, ChatCompletionMessageParam
from chunk_text import get_chunk_bbox, split_text_preserve_sentences, data_replace, chunk_check,merge_short_slices,find_and_read_content_list,process_pdf_file,process_other_file
from chunk_text import get_chunk_bbox, split_text_preserve_sentences, data_replace, chunk_check,merge_short_slices,find_and_read_content_list,process_pdf_file,process_other_file,safe_original_filename,image_base64_to_data_url,extract_image_text,prepare_split_image
from fastapi.responses import JSONResponse, StreamingResponse
import requests
from pathlib import PurePath, Path as PathLib
@ -63,14 +63,20 @@ from doc2pdf import Doc2PDF
from workflow_registry import WORKFLOW_CONFIG, VALID_ROUTE_FLAGS
from main_agent import extract_conversation_title
from workflows.history_manager import filter_image_urls, preprocess_from_request
from config import set_request_user_config
from config import VLM_CONCURRENCY, set_request_user_config
from checkpointer_config import (
CheckpointerManager,
checkpointer_manager
)
app = FastAPI(max_request_size=1024 * 1024 * 10)
converter = Doc2PDF()
# =============== PDF 文件目录配置 ===============
# 创建 PDF 文件目录(用于存储生成的 PDF 等文件)
PDF_DIR = os.path.join(os.path.dirname(__file__), "created_pdf")
# 创建 word 文件目录(用于存储生成的 word 等文件)
WORD_DIR = os.path.join(os.path.dirname(__file__), "created_word")
os.makedirs(PDF_DIR, exist_ok=True)
VLM_SEMAPHORE = asyncio.Semaphore(max(1, VLM_CONCURRENCY))
converter = None
DATA_DIR = "/app/files"
load_dotenv()
@ -93,6 +99,13 @@ STREAM_RESPONSE_HEADERS = {
}
def get_doc_converter() -> Doc2PDF:
global converter
if converter is None:
converter = Doc2PDF()
return converter
def llm_text_stream_response(content) -> StreamingResponse:
return StreamingResponse(
content,
@ -104,20 +117,16 @@ def llm_text_stream_response(content) -> StreamingResponse:
@app.on_event("startup")
async def startup_event():
"""应用启动时初始化"""
await checkpointer_manager.setup()
# 初始化智能体使用统计表
from tools.agent_usage_statistics import init_agent_usage_table
await init_agent_usage_table()
logger.info("应用启动完成PostgreSQL Checkpointer 已配置")
try:
await checkpointer_manager.setup()
# 初始化智能体使用统计表
from tools.agent_usage_statistics import init_agent_usage_table
await init_agent_usage_table()
logger.info("应用启动完成PostgreSQL Checkpointer 已配置")
except Exception as e:
logger.warning(f"Checkpointer 初始化失败,非 Agent 接口仍可使用: {e}")
# =============== PDF 文件目录配置 ===============
# 创建 PDF 文件目录(用于存储生成的 PDF 等文件)
PDF_DIR = os.path.join(os.path.dirname(__file__), "created_pdf")
# 创建 word 文件目录(用于存储生成的 word 等文件)
WORD_DIR = os.path.join(os.path.dirname(__file__), "created_word")
os.makedirs(PDF_DIR, exist_ok=True)
class RewriteRequest(BaseModel):
rewrite_type: Optional[str] = None
content: Optional[str] = None
@ -1509,6 +1518,75 @@ class RequestWrapper(BaseModel):
pdf_contents: List[PdfContentItem]
class Base64ImageRequest(BaseModel):
content: Optional[str] = Field(
None,
description="Optional text around the image, for example: 图3.1 船舶主发动机组成图images/test.jpg",
)
filename: str = Field(..., description="Original image filename, for example test.png")
image_base64: str = Field(..., description="Base64 image content, with or without data:image/... prefix")
async def analyze_image_with_vlm(image_bytes: bytes, suffix: str) -> str:
image_url = image_base64_to_data_url(image_bytes, suffix)
async with VLM_SEMAPHORE:
return await model_api.OpenaiAPI.open_api_vl_without_thinking(image_url)
@app.post("/split_image")
async def image_base64(request_data: Base64ImageRequest):
filename = safe_original_filename(request_data.filename)
suffix = PathLib(filename).suffix.lower()
if suffix not in {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".gif", ".tif", ".tiff"}:
raise HTTPException(status_code=400, detail="Unsupported image type")
image_bytes, processed_suffix = prepare_split_image(request_data.image_base64, suffix)
if not image_bytes:
raise HTTPException(status_code=400, detail="decoded image is empty")
temp_path = None
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=processed_suffix) as tmp:
tmp.write(image_bytes)
temp_path = PathLib(tmp.name)
ocr_data = await extract_image_text(temp_path)
try:
temp_path.unlink()
except Exception as e:
logger.warning(f"Failed to delete temp image {temp_path}: {e}")
finally:
temp_path = None
content = (request_data.content or "").strip()
ocr_full_text = (ocr_data.get("full_text") or "").strip()
if not content and not ocr_full_text:
vlm_text = await analyze_image_with_vlm(image_bytes, processed_suffix)
image_content = "图片文本描述为:" + (vlm_text or "").strip()
elif content and ocr_full_text:
image_content = f"图片上下文内容为:{content}, {ocr_full_text}"
elif content:
image_content = "图片上下文内容为:" + content
else:
image_content = ocr_full_text
image_content = "图片文件名为:" + filename + ",图片相关内容为:" + image_content
return JSONResponse(
{
"code": 200,
"message": "ok",
"data": {
"image_content": image_content,
"filename": filename,
},
}
)
finally:
if temp_path and temp_path.exists():
try:
temp_path.unlink()
except Exception as e:
logger.warning(f"Failed to delete temp image {temp_path}: {e}")
@app.post("/split_content_list")
@ -1590,7 +1668,7 @@ async def split_result(
logger.info(f"转换 DOCX -> PDF: {filename}")
# 执行转换
converter.convert(str(temp_input_path), output_dir=str(DATA_DIR))
get_doc_converter().convert(str(temp_input_path), output_dir=str(DATA_DIR))
# ? 使用临时文件的基础名查找 PDF
temp_base_name = temp_input_path.stem # 如 tmp2bb0l4cn

2994
app_v2.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -8,20 +8,293 @@ 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 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()
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 = "图片包含的文字内容为:" + ",".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 "图片包含的文字内容为:" in markdown_image:
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}"
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. 定义实体类型与属性的映射关系
# 格式: '实体类型': [ ('前缀1', '属性1'), ('前缀2', '属性2') ]
@ -709,18 +982,35 @@ 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"]
if isinstance(img_path, list):
ins["img_path"] = "\n".join(
data_replace([{"img_path": str(item)}], prefix)[0]["img_path"]
for item in img_path
if str(item).strip()
)
continue
img_path = str(img_path)
if img_path.strip().startswith("!["):
local_image_path = resolve_local_image_path(img_path)
ins["img_path"] = build_image_markdown_with_ocr(img_path, local_image_path)
continue
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})"
markdown_image = f"![图片]({prefix}{relative_path})"
local_image_path = resolve_local_image_path(relative_path)
ins["img_path"] = build_image_markdown_with_ocr(markdown_image, local_image_path)
else:
# 否则保留原路径(或按需处理)
ins["img_path"] = f"![图片]({prefix}{img_path})"
markdown_image = f"![图片]({prefix}{img_path})"
local_image_path = resolve_local_image_path(img_path)
ins["img_path"] = build_image_markdown_with_ocr(markdown_image, local_image_path)
return data
def filter_content_bbox(data, max_length=3000):
@ -956,6 +1246,42 @@ 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 find_and_read_content_list(directory, original_filename, encoding='utf-8'):
"""
@ -998,8 +1324,7 @@ async def process_pdf_file(pdf_path: Path, image_prefix: str,filename:str) -> Op
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:
@ -1021,8 +1346,9 @@ async def process_pdf_file(pdf_path: Path, image_prefix: str,filename:str) -> Op
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": {}}
print(11111111111111111111111111111111111111)
print(len(images))
return {"slices": slices_check, "images": images}
except json.JSONDecodeError:
logger.error("❌ 响应不是有效的 JSON 格式")
@ -1044,10 +1370,49 @@ async def process_other_file(pdf_path: Path, image_prefix: str, filename: str) -
replaced_content_list = data_replace(content_list, 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_list = data_replace(content_list, 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"

View File

@ -228,3 +228,18 @@ API_URLS = [
#"http://192.168.0.111:9980/analyze-pdf",
# "http://192.168.0.111:9975/analyze-pdf",
]
# ==================== 切片图片处理配置 ====================
MAX_SPLIT_IMAGE_INPUT_BYTES = 50 * 1024 * 1024
MAX_SPLIT_IMAGE_BYTES = 10 * 1024 * 1024
MAX_SPLIT_IMAGE_BASE64_CHARS = (MAX_SPLIT_IMAGE_INPUT_BYTES * 4 // 3) + 4096
SPLIT_IMAGE_COMPRESS_THRESHOLD_BYTES = MAX_SPLIT_IMAGE_BYTES
SPLIT_IMAGE_MAX_DIMENSION = 4096
SPLIT_IMAGE_JPEG_QUALITY = 88
OCR_CONCURRENCY = 1
VLM_CONCURRENCY = 2
SHIP_MODEL_NAME = "/app/ship_xinghao.json"
TREE_JSON_PATH = "/app/tree_data.json"
# ==================== mineru和kgrag目录映射地址 ====================
SEARCH_DIR ="/app/mineru_output"
IMAGE_DIR = "/app/mineru_output/images"

View File

@ -0,0 +1,857 @@
"""
PDF 用户上传接口FastAPI
- 处理文件名和其对应关系
"""
from typing import Dict, Tuple
import os
import re
from typing import Dict
from fastapi import FastAPI, HTTPException
import logging
import json
from modelsAPI.model_api import OpenaiAPI
from openai import OpenAI
from typing import Dict, List, Any
from config import TREE_JSON_PATH
app = FastAPI(title="PDF Upload Service")
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(name)s | %(message)s")
logger = logging.getLogger("filename_neo4j_process")
# =========================
# 图方法
# =========================
def node_to_json(node):
props = dict(node)
# ks_raw = props.get("knowledge_source")
# knowledge_source = json.loads(ks_raw) if ks_raw else []
ks_raw = props.get("knowledge_source")
knowledge_source = []
if ks_raw and isinstance(ks_raw, str):
try:
ks_list = json.loads(ks_raw)
if isinstance(ks_list, list):
for ks in ks_list:
if isinstance(ks, dict):
ks.pop("info", None)
knowledge_source.append(ks)
except (json.JSONDecodeError, TypeError):
# 解析失败或类型错误时,保持 knowledge_source 为空列表
pass
name = props.get("名称")
props.pop("embedding", None)
props.pop("knowledge_source", None)
props.pop("name", None) # 现在安全地移除
props.pop("fulltext", None)
if "last_updated" in props:
props["最后更新时间"] = props.pop("last_updated")
if "created_at" in props:
props["创建时间"] = props.pop("created_at")
return {
"id": node.element_id,
"name": name, # 使用提前保存的值
"labels": list(node.labels),
"knowledge_source": knowledge_source,
"properties": props,
}
def rel_to_json(rel):
props = dict(rel)
props.pop("embedding", None)
props.pop("fact_timeline", None)
return {"id": rel.element_id, "label": rel.type, "source": rel.start_node.element_id, "target": rel.end_node.element_id, "properties": props}
def is_simple_path(path):
"""
判断是否为无环路径simple path
"""
seen = set()
for n in path.nodes:
nid = n.element_id
if nid in seen:
return False
seen.add(nid)
return True
def build_tree(nodes: List[Dict], links: List[Dict]) -> List[Dict]:
"""
按照固定层级构建树舰船() -> 舰艇 -> 系统 -> 子系统
不再依赖 links 的连线直接根据 labels 归类彻底避免环状数据导致的死循环
默认只展开第一艘舰艇的系统/子系统其它舰艇 children 置空前端可点击按需加载
"""
# 1. 辅助函数:判断标签是否包含关键字
def has_label(node, keyword):
labels = node.get("labels", [])
if isinstance(labels, list):
return any(keyword in lbl for lbl in labels)
return False
# 2. 辅助函数:构建标准的树节点格式
def make_node(node_data):
return {
"id": node_data["id"],
"name": node_data.get("name", ""),
"labels": node_data.get("labels", []),
"properties": node_data.get("properties", {}),
"color": node_data.get("color"),
"size": node_data.get("size"),
"children": []
}
# 3. 将节点按层级分类存储到字典中,方便快速查找
fleet_roots = {} # 第零层:舰船/舰号(父根节点)
ships = {} # 第一层:舰艇
systems = {} # 第二层:系统(不含子系统)
sub_systems = {} # 第三层:子系统
for node in nodes:
# 注意:判断逻辑要互斥,防止"子系统"被误判为"系统"
if has_label(node, "舰船") or has_label(node, "舰号"):
fleet_roots[node["id"]] = make_node(node)
elif has_label(node, "舰艇"):
ships[node["id"]] = make_node(node)
elif has_label(node, "子系统"):
sub_systems[node["id"]] = make_node(node)
elif has_label(node, "系统"):
systems[node["id"]] = make_node(node)
else:
pass
# 4. 组装系统 -> 子系统、舰艇 -> 系统 关系(先把完整子树挂好)
for link in links:
if link.get("label") != "包含":
continue
src_id = link["source"]
tgt_id = link["target"]
if src_id in ships and tgt_id in systems:
ships[src_id]["children"].append(systems[tgt_id])
elif src_id in systems and tgt_id in sub_systems:
systems[src_id]["children"].append(sub_systems[tgt_id])
elif src_id in ships and tgt_id in sub_systems:
ships[src_id]["children"].append(sub_systems[tgt_id])
# 5. 所有舰艇 children 全部保留,仅用 collapsed 标记默认折叠状态。
# 前端读取 collapsed=False 的舰艇默认展开,其它折叠;
# 用户点击折叠的舰艇时,前端切换 collapsed 即可展示子树。
# 只保留名称包含 "122" 的舰艇(隐藏 JZ贱 等其它分支)
#sorted_ships = sorted(
# (s for s in ships.values() if "122" in s.get("name", "")),
# key=lambda s: s.get("name", ""),
#)
sorted_ships = sorted(ships.values(), key=lambda s: s.get("name", ""))
for idx, ship in enumerate(sorted_ships):
ship["collapsed"] = (idx != 0)
# 6. 挂载到舰船根节点;如果数据里没有舰船根,就创建一个虚拟根
if fleet_roots:
# 取第一个舰船根节点作为父(一般场景下只有一个)
root = next(iter(fleet_roots.values()))
root["children"] = sorted_ships
return [root]
else:
virtual_root = {
"id": "__fleet_root__",
"name": "舰号",
"labels": ["舰船"],
"properties": {},
"color": None,
"size": None,
"children": sorted_ships,
}
return [virtual_root]
def build_tree1(nodes: List[Dict], links: List[Dict]) -> List[Dict]:
"""
nodes + links 构建树结构
- 根节点label 包含 '舰艇' 的节点
- 父子关系links label == '包含' 的边 (source -> target)
- 支持多棵树多个舰艇根节点
"""
# 1. 建立 id -> node 的映射
node_map: Dict[str, Dict] = {n["id"]: n for n in nodes}
# 2. 建立 父id -> [子id列表] 的邻接表(只处理"包含"关系)
children_map: Dict[str, List[str]] = {n["id"]: [] for n in nodes}
parent_map: Dict[str, str] = {} # 子id -> 父id用于识别根节点
for link in links:
if link.get("label") == "包含":
src = link["source"]
tgt = link["target"]
if src in children_map:
children_map[src].append(tgt)
if tgt not in parent_map:
parent_map[tgt] = src
# 3. 递归构建子树
def build_subtree(node_id: str, ancestors: set = None) -> Dict[str, Any]:
if ancestors is None:
ancestors = set()
if node_id in ancestors:
return None
ancestors.add(node_id)
node = node_map[node_id]
subtree = {"id": node_id, "name": node.get("name", ""), "labels": node.get("labels", []), "properties": node.get("properties", {}), "color": node.get("color"), "size": node.get("size"), "children": []}
current_ancestors = ancestors | {node_id}
for child_id in children_map.get(node_id, []):
child = build_subtree(child_id, current_ancestors)
if child is not None:
subtree["children"].append(child)
return subtree
# def build_subtree(node_id: str) -> Dict[str, Any]:
# node = node_map[node_id]
# subtree = {
# "id": node_id,
# "name": node.get("name", ""),
# "labels": node.get("labels", []),
# "properties": node.get("properties", {}),
# "color": node.get("color"),
# "size": node.get("size"),
# "children": []
# }
# for child_id in children_map.get(node_id, []):
# subtree["children"].append(build_subtree(child_id))
# return subtree
# 4. 找出所有根节点label 包含 '舰艇',且没有父节点
roots = [n["id"] for n in nodes if "舰艇" in n.get("labels", []) and n["id"] not in parent_map]
# 5. 为每棵树构建结构,并打上树编号
forest = []
for i, root_id in enumerate(roots):
tree = build_subtree(root_id)
tree["tree_index"] = i # 区分不同树
forest.append(tree)
return forest
def _filter_searchable(graph: dict) -> dict:
"""剥掉节点的 Searchable 标签;若剥完后 labels 为空则整体移除该节点及其关联边"""
for n in graph["nodes"]:
n["labels"] = [l for l in n.get("labels", []) if l != "Searchable"]
valid_ids = {n["id"] for n in graph["nodes"] if n["labels"]}
return {
"nodes": [n for n in graph["nodes"] if n["id"] in valid_ids],
"links": [
l for l in graph["links"]
if l["source"] in valid_ids and l["target"] in valid_ids
],
}
def build_graph_from_paths(paths):
"""
核心公共方法
Neo4j 路径结果构建 graphData
"""
node_map: Dict[str, dict] = {}
link_map: Dict[str, dict] = {}
def add_edge(rel):
s = rel.start_node
t = rel.end_node
if s.element_id not in node_map:
node_map[s.element_id] = node_to_json(s)
if t.element_id not in node_map:
node_map[t.element_id] = node_to_json(t)
if rel.element_id not in link_map:
link_map[rel.element_id] = rel_to_json(rel)
for record in paths:
p = record["p"]
if not is_simple_path(p):
continue
for r in p.relationships:
add_edge(r)
return _filter_searchable({"nodes": list(node_map.values()), "links": list(link_map.values())})
# def fetch_graph_sample(driver,limit: int = 200):
# """提取采样逻辑为可复用函数"""
# with driver.session() as session:
# result = session.run("""
# MATCH (n)
# WITH n
# ORDER BY elementId(n) ASC
# LIMIT $limit
# MATCH (n)-[rel]->(m)
# RETURN n, rel, m
# """, limit=limit)
# node_map, link_map = {}, {}
# for rec in result:
# n, r, m = rec["n"], rec["rel"], rec["m"]
# node_map[n.element_id] = node_to_json(n)
# node_map[m.element_id] = node_to_json(m)
# link_map[r.element_id] = rel_to_json(r)
# return {
# "nodes": list(node_map.values()),
# "links": list(link_map.values())
# }
# def fetch_graph_sample(driver, limit: int = 200):
# """
# 获取采样数据:
# 1. 选取前 $limit 个种子节点Level 0 或 1取决于业务定义这里定义种子为 Level 0
# 2. 对每个节点向下探索最多 3 跳。
# 3. 每个节点对象中增加 level 字段,表示其距离种子节点的深度。
# """
# with driver.session() as session:
# # Cypher 逻辑:
# # - 选出种子节点 n
# # - 匹配 1-3 跳路径
# # - 返回种子节点 n路径 path以及该路径的长度即层级
# result = session.run("""
# MATCH (n)
# WITH n ORDER BY elementId(n) ASC LIMIT $limit
# OPTIONAL MATCH path = (n)-[*1..3]-(m)
# UNWIND nodes(path) AS x
# WITH n, path, m, COUNT(DISTINCT x) AS nodeCount
# WHERE path IS NULL OR nodeCount = LENGTH(path) + 1
# RETURN n, path, LENGTH(path) AS depth
# """, limit=limit)
# node_map, link_map = {}, {}
# for rec in result:
# seed_node = rec["n"]
# path = rec["path"]
# depth = rec["depth"] or 0 # 如果没有路径,深度为 0
# # 1. 处理种子节点 (Level 0)
# if seed_node.element_id not in node_map:
# node_data = node_to_json(seed_node)
# node_data["level"] = 0 # 种子节点设为 0 层
# node_map[seed_node.element_id] = node_data
# # 2. 如果存在路径,解析路径中的所有节点和关系
# if path:
# # 路径中的节点处理
# # path.nodes 包含了从起始到终点的所有节点
# for i, node in enumerate(path.nodes):
# if node.element_id not in node_map:
# node_data = node_to_json(node)
# # 层级即为该节点在当前路径中的索引
# node_data["level"] = i
# node_map[node.element_id] = node_data
# else:
# # 如果节点已存在,保留最小的 level (即最靠近种子的距离)
# node_map[node.element_id]["level"] = min(node_map[node.element_id].get("level", 3), i)
# # 路径中的关系处理
# for rel in path.relationships:
# if rel.element_id not in link_map:
# link_map[rel.element_id] = rel_to_json(rel)
# return {
# "nodes": list(node_map.values()),
# "links": list(link_map.values())
# }
# def fetch_graph_sample(driver, limit=200, max_depth=3):
# with driver.session() as session:
# result = session.run("""
# MATCH (c)
# WITH c ORDER BY elementId(c) ASC LIMIT $limit
# MATCH path = (c)-[*0..3]-(n)
# WITH n, MIN(length(path)) AS dist
# OPTIONAL MATCH (n)-[r]-(m)
# RETURN n, dist, r, m
# """, limit=limit)
# node_map, link_map = {}, {}
# for rec in result:
# n = rec["n"]
# dist = rec["dist"]
# r = rec["r"]
# m = rec["m"]
# # ---- 节点 ----
# nid = n.element_id
# if nid not in node_map:
# node_data = node_to_json(n)
# node_data["level"] = min(dist, 3) if dist is not None else 0
# node_map[nid] = node_data
# # ---- 边 ----
# if r and m:
# mid = m.element_id
# if mid not in node_map:
# node_map[mid] = node_to_json(m)
# link_map.setdefault(r.element_id, rel_to_json(r))
# return {
# "nodes": list(node_map.values()),
# "links": list(link_map.values())
# }
def fetch_graph_sample(driver):
"""
查询所有舰艇及其关联的系统和子系统
路径结构(舰艇) -> (系统) -> (子系统)
"""
with driver.session() as session:
# =========================
# 统一查询:舰艇 -> 系统 -> 子系统
# =========================
# 逻辑说明:
# 1. MATCH (ship:舰艇):选中所有舰艇
# 2. -[*1..3]->(target):查找深度为 1 到 3 的路径
# - 深度 1舰艇 -> 系统
# - 深度 2舰艇 -> 系统 -> 子系统
# 3. WHERE target:系统 OR target:子系统:确保终点是我们关心的节点类型
# (防止查询出其他无关的深层节点)
query = """
MATCH path = (ship:舰艇)-[*1..3]->(target)
WHERE target:系统 OR target:子系统
RETURN path
"""
result = session.run(query)
# =========================
# 解析 paths
# =========================
all_nodes = {}
all_links = {}
for record in result:
path = record["path"]
# 解析节点
for node in path.nodes:
# 兼容 ID 获取
node_key = node.element_id if hasattr(node, 'element_id') else node.id
if node_key not in all_nodes:
all_nodes[node_key] = node_to_json(node)
# 解析关系
for rel in path.relationships:
rel_key = rel.element_id if hasattr(rel, 'element_id') else rel.id
if rel_key not in all_links:
all_links[rel_key] = rel_to_json(rel)
return _filter_searchable({
"nodes": list(all_nodes.values()),
"links": list(all_links.values())
})
def fetch_graph_sample1(driver):
"""
查找路径
1. 优先舰艇 -> 子系统
2. fallback舰艇 -> 系统
注意根据数据库实际结构使用 Label (:舰艇, :子系统) 进行匹配
而不是 WHERE category = '...'
"""
# 定义采样数量
SAMPLE_LIMIT = 400
with driver.session() as session:
# =========================
# 第一阶段:舰艇 -> 子系统
# =========================
# 修改点:直接使用 (:舰艇) 和 (:子系统) 标签匹配,去掉 WHERE 子句
result = session.run(
"""
MATCH (ship:舰艇)
WITH ship LIMIT $limit
MATCH path = (ship)-[*1..3]->(sub:子系统)
RETURN path
""",
limit=SAMPLE_LIMIT,
)
paths = [record["path"] for record in result]
# =========================
# fallback舰艇 -> 系统
# =========================
if not paths:
# 修改点:直接使用 (:舰艇) 和 (:系统) 标签匹配
result = session.run(
"""
MATCH (ship:舰艇)
WITH ship LIMIT $limit
MATCH path = (ship)-[*1..2]->(sys:系统)
RETURN path
""",
limit=SAMPLE_LIMIT,
)
paths = [record["path"] for record in result]
# =========================
# 解析 paths
# =========================
all_nodes = {}
all_links = {}
for path in paths:
# 节点
for node in path.nodes:
# 兼容 Neo4j 5.x (element_id) 和 4.x (id)
node_key = node.element_id if hasattr(node, "element_id") else node.id
if node_key not in all_nodes:
all_nodes[node_key] = node_to_json(node)
# 关系
for rel in path.relationships:
rel_key = rel.element_id if hasattr(rel, "element_id") else rel.id
if rel_key not in all_links:
all_links[rel_key] = rel_to_json(rel)
return {"nodes": list(all_nodes.values()), "links": list(all_links.values())}
# =============================
# 文件名处理方法
# =============================
# BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# TREE_JSON_PATH = os.path.join(BASE_DIR, "tree_data.json")
def _load_tree_data() -> Dict:
# 注意:实际运行时请确保 result.json 存在,此处仅为代码结构展示
if not os.path.exists(TREE_JSON_PATH):
return {}
with open(TREE_JSON_PATH, "r", encoding="utf-8") as f:
return json.load(f)
def _resolve_ship_root(data: Dict, ship_code: str) -> Dict:
raw = data.get(ship_code)
if not isinstance(raw, dict):
return {}
# 结构 1直接是节点 {"code","name","children"}
if {"code", "name", "children"}.issubset(raw.keys()):
return raw
# 结构 2包装层 {"101": {...root node...}}
if ship_code in raw and isinstance(raw[ship_code], dict):
node = raw[ship_code]
if {"code", "name", "children"}.issubset(node.keys()):
return node
# 兜底:取第一个 node-like 值
for value in raw.values():
if isinstance(value, dict) and {"code", "name", "children"}.issubset(value.keys()):
return value
return {}
def _longest_common_substring_len(a: str, b: str) -> int:
if not a or not b:
return 0
# 优化:确保 a 是较短的字符串以节省空间(可选),原逻辑保持不动也没问题
dp = [0] * (len(b) + 1)
best = 0
for i in range(1, len(a) + 1):
prev = 0
for j in range(1, len(b) + 1):
temp = dp[j]
if a[i - 1] == b[j - 1]:
dp[j] = prev + 1
if dp[j] > best:
best = dp[j]
else:
dp[j] = 0
prev = temp
return best
def _match_level4_tool(filename_text: str, device_node: Dict) -> Tuple[str, str]:
"""
匹配 Level 4 零件
规则如果文件名与 Level 3 (设备名) 的最大共同字符串长度 >= 与任何 Level 4 (零件名) 的长度
则只匹配到 Level 3返回空
只有当 Level 4 的匹配度严格高于 Level 3 才返回 Level 4 信息
"""
children = device_node.get("children") or {}
# 1. 计算文件名与当前设备 (Level 3) 名称的匹配度
device_name = str(device_node.get("name") or "")
level3_score = _longest_common_substring_len(filename_text, device_name)
best_name = ""
best_code = ""
best_score = 0 # 记录 Level 4 中的最佳得分
# 2. 遍历 Level 4 子节点
for child in children.values():
tool_name = str(child.get("name") or "")
tool_code = str(child.get("code") or "")
score = _longest_common_substring_len(filename_text, tool_name)
# 关键修改:只有当 Level 4 的得分严格大于 Level 3 的得分时,才视为有效匹配
# 并且要比当前找到的其他 Level 4 更好
if score > level3_score and score > best_score:
best_score = score
best_name = tool_name
best_code = tool_code
# 如果没有找到比 Level 3 匹配度更高的 Level 4则返回空
if best_score <= level3_score:
return "", ""
return best_name, best_code
def process_filename(filename: str) -> Dict[str, str]:
name, _ = os.path.splitext(os.path.basename(filename))
parts = re.split(r"[_-]", name)
xian_number = ""
level_1_system_name = ""
level_2_system_name = ""
device_name = ""
tool_name = ""
tool_code = ""
if not parts:
return {}
xian_number_code = parts[0].strip()
level1_code = ""
level2_code = ""
device_code = ""
if len(parts) > 1:
part = parts[1].strip()
# 防止索引越界
if len(part) < 2:
return {}
level1_code = part[:2]
level2_code = part[2:4] if len(part) > 2 else ""
device_code = part[4:] if len(part) > 4 else ""
# 若 parts[2] 存在,则用 parts[2] 作为 device_code支持 122-06A0014-B01001_发动机 格式)
if len(parts) > 2:
device_code_alt = parts[2].strip()
if device_code_alt:
device_code = device_code_alt
data = _load_tree_data()
root = _resolve_ship_root(data, xian_number_code)
if not root:
return {}
xian_number = root.get("name", "")
# 一级
s1 = root.get("children", {}).get(level1_code)
if s1:
level_1_system_name = s1.get("name", "")
# 二级
s2 = s1.get("children", {}).get(level2_code)
if s2:
level_2_system_name = s2.get("name", "")
# 三级设备
device = s2.get("children", {}).get(device_code)
if device:
device_name = device.get("name", "")
# 若存在 level4匹配与文件名最大相同字符串的零件
# 内部已包含逻辑:如果设备名匹配度更高,则不返回零件
tool_name, tool_code = _match_level4_tool(name, device)
return {
"Xian_Number": xian_number,
"Xian_Number_code": xian_number_code,
"Level_1_System_Name": level_1_system_name,
"level1_code": level1_code,
"Level_2_System_Name": level_2_system_name,
"level2_code": level2_code,
"Device_Name": device_name,
"device_code": device_code,
"tool_name": tool_name,
"tool_code": tool_code,
}
def get_entity(filename):
result = process_filename(filename)
entities = []
relationships = []
low_level = ""
if result:
xian_number = result.get("Xian_Number")
xian_number_code = result.get("Xian_Number_code")
level_1_system_name = result.get("Level_1_System_Name")
level1_code = result.get("level1_code")
level_2_system_name = result.get("Level_2_System_Name")
level2_code = result.get("level2_code")
device_name = result.get("Device_Name")
device_code = result.get("device_code")
tool_name = result.get("tool_name")
tool_code = result.get("tool_code")
if xian_number:
entities.append(
{
"type": "舰艇",
"properties": {
"名称": xian_number,
"舷号": xian_number_code,
},
}
)
low_level = xian_number
if level_1_system_name:
entities.append(
{
"type": "系统",
"properties": {
"名称": level_1_system_name,
"系统编码": level1_code,
},
}
)
low_level = level_1_system_name
if xian_number and level_1_system_name:
relationships.append(
{
"type": "包含",
"from_entity": xian_number,
"to_entity": level_1_system_name,
}
)
if level_2_system_name:
entities.append(
{
"type": "子系统",
"properties": {
"名称": level_2_system_name,
"子系统编码": level2_code,
},
}
)
low_level = level_2_system_name
if level_1_system_name and level_2_system_name:
relationships.append(
{
"type": "包含",
"from_entity": level_1_system_name,
"to_entity": level_2_system_name,
}
)
if device_name:
entities.append(
{
"type": "设备",
"properties": {
"名称": device_name,
"设备编码": device_code,
},
}
)
low_level = device_name
if level_2_system_name and device_name:
relationships.append(
{
"type": "包含",
"from_entity": level_2_system_name,
"to_entity": device_name,
}
)
if tool_name:
entities.append(
{
"type": "零件",
"properties": {
"名称": tool_name,
"零件编码": tool_code,
},
}
)
low_level = tool_name
if device_name and tool_name:
relationships.append(
{
"type": "包含",
"from_entity": device_name,
"to_entity": tool_name,
}
)
final_result = {
"entities": entities,
"relationships": relationships,
"low_level": low_level,
}
return final_result
# 示例测试逻辑(非必须,仅供验证)
if __name__ == "__main__":
# 阀控蓄电池脉冲式快速充电装置
test_list = [
"163-06A0015-B01001_发动机-维修手册.pdf",
]
for i in test_list:
result = get_entity(i)
lower_entity = result.get("low_level")
print(result)
print(lower_entity)
#from neo4j import GraphDatabase
#NEO4J_URI = "bolt://192.168.0.111:7687"
#NEO4J_USER = os.getenv("NEO4J_USER", "neo4j")
#NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD","zdht123@") # 不设默认值,强制要求提供
#NEO4J_DATABASE = os.getenv("NEO4J_DATABASE", "neo4j")
#driver = GraphDatabase.driver(
# NEO4J_URI,
# auth=(NEO4J_USER, NEO4J_PASSWORD),
# database=NEO4J_DATABASE
#)
#res = fetch_graph_sample(driver)
#print(res)

View File

@ -5,7 +5,7 @@ import asyncio
import logging
from typing import Optional, Tuple, Dict
import aiofiles # pip install aiofiles
from config import SEARCH_DIR,IMAGE_DIR
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@ -103,7 +103,6 @@ async def encode_images_to_base64(
async def process_document(
input_file_name: str,
search_dir: Optional[str] = "/app/mineru_output",
local_image_dir: Optional[str] = None,
concurrency: int = 32,
) -> Optional[Tuple[list, Dict[str, str]]]:
@ -120,6 +119,7 @@ async def process_document(
成功: (content_list, images_dict)
失败: None
"""
search_dir = SEARCH_DIR
content, content_path = await find_and_read_content_list(search_dir, input_file_name)
if content is None:
logger.error("❌ 未找到指定的 _content_list.json 文件。")

14
jpgextract.py Normal file
View File

@ -0,0 +1,14 @@
from rapidocr_onnxruntime import RapidOCR
engine = RapidOCR()
ocr_result, elapse = engine(r"E:\ZKYNLP\aidocproject\audiotransaction\gwdoc\图片.png")
texts = [item[1] for item in (ocr_result or [])]
full_text = "图片包含的文字内容为:"+",".join(texts)
print("\n合并文本:")
print(full_text)
print("\n耗时:", elapse)

Binary file not shown.

Binary file not shown.

View File

@ -5,7 +5,6 @@ import os
import numpy as np
from openai import OpenAI
from config import LLM_CONFIG, EMBEDDING_CONFIG
from neo4j_graphrag.embeddings.base import Embedder
from openai import AsyncOpenAI
@ -141,7 +140,7 @@ class OpenaiAPI:
# 2. 构建强约束的 System Prompt
# 核心目标:禁止思考标签,禁止废话,只给结果
system_prompt = (
"你是一个工业视觉分析专家。你的任务是从图片中识别设备并诊断故障\n"
"你是一个视觉分析专家。你的任务是从图片中识别内容并进行描述\n"
"【严格约束】\n"
"1. 绝对禁止输出 <think>, <think>, <reasoning> 等任何思考过程标签。\n"
"2. 绝对禁止输出'好的''根据图片''分析如下'等开场白或结束语。\n"
@ -151,10 +150,7 @@ class OpenaiAPI:
# 3. 构建针对性的 User Prompt
default_task = (
"请分析这张图片,描述图片内容,重点关注以下信息\n"
"1. 设备识别:图中是什么设备?型号或标签是否可见?\n"
"2. 状态评估:仪表盘数值是否在正常区间?指示灯颜色是什么?\n"
"3. 异常检测:是否存在漏油、生锈、电缆破损、螺丝松动或烟雾?\n"
"4. 环境风险:周围是否有杂物堆放或安全隐患?\n"
"1. 图片整体阐述了什么主题,描述了哪些内容\n"
"请用专业、客观、简短的语言描述。"
)
@ -185,7 +181,7 @@ class OpenaiAPI:
kwargs["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}}
try:
print(f"🚀 正在调用 {model} 进行设备故障分析...")
print(f"🚀 正在调用 {model} 进行图片内容分析...")
response = await client.chat.completions.create(**kwargs)
raw_content = response.choices[0].message.content or ""
@ -194,7 +190,7 @@ class OpenaiAPI:
except Exception as e:
import traceback
error_msg = f"设备故障分析失败:{str(e)}\n{traceback.format_exc()}"
error_msg = f"图片内容分析失败:{str(e)}\n{traceback.format_exc()}"
print(error_msg)
return f"Error: {str(e)}"
@ -353,24 +349,7 @@ class OpenaiAPI:
return 0.5 * jaccard + 0.5 * len_penalty
class LocalBgeM3Embeddings(Embedder):
def __init__(self, base_url: str, api_key: str, model_name: str = "bge-m3"):
self.base_url = base_url
self.api_key = api_key
self.model_name = model_name
def embed_query(self, text: str) -> List[float]:
return self.embed_documents([text])[0]
def embed_documents(self, texts: List[str]) -> List[List[float]]:
url = self.base_url
with httpx.Client(timeout=60) as client:
response = client.post(
url,
json={"model": self.model_name, "input": texts},
headers={"Authorization": f"Bearer {self.api_key}"},
)
return [item["embedding"] for item in response.json()["data"]]
if __name__ == "__main__":

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

BIN
test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
test2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
船舶图片.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB