import json
import fitz # PyMuPDF
import os
from collections import Counter
import uuid
# 假设 Prompt 和 extract_util 是你本地已有的模块
from kg_build.Prompt import prompt_extract_operation_node_rela
from kg_build.extract_util import safe_json_loads, highlight_pdf_file, get_content_bbox
import re
import asyncio
from openai import AsyncOpenAI
# from modelsAPI.model_api import OpenaiAPI
async def openai_chat_aysnc(query: str, timeout: int = 180) -> str | None:
"""异步调用 OpenAI 兼容 API(如本地部署的 Qwen、vLLM、Ollama 等)"""
client = None
try:
base_url = "http://192.168.0.46:59800/v1"
api_key = "none"
client = AsyncOpenAI(
api_key=api_key,
base_url=base_url,
timeout=timeout
)
response = await client.chat.completions.create(
model="Qwen3.5-35B-A3B",
messages=[
{"role": "user", "content": query},
],
temperature=0.1,
stream=False,
response_format={"type": "json_object"},
extra_body={"chat_template_kwargs": {"enable_thinking": False}}
)
return response.choices[0].message.content
except Exception as e:
print(f"调用 OpenAI API 时出错: {e}")
return None
finally:
if client:
await client.close()
def is_operation_table_by_first_tr(table_body: str) -> bool:
"""
判断 table_body 是否为操作项目表,依据是第一行
中是否包含 '项目编号' 和 '操作项目'
"""
if not isinstance(table_body, str):
return False
# 非贪婪匹配第一个
...
match = re.search(r']*>(.*?)
', table_body, re.IGNORECASE | re.DOTALL)
if not match:
return False
first_tr_html = match.group(1)
# 去除所有 HTML 标签,提取纯文本
text_in_first_tr = re.sub(r'<[^>]+>', '', first_tr_html)
# 转为小写(中文无所谓大小写,但统一处理更安全)
text_clean = text_in_first_tr.strip()
# 检查是否同时包含两个关键词
has_code = "项目编号" in text_clean
has_item = "操作项目" in text_clean
return has_code and has_item
def extract_operation_groups_and_remaining(records, max_following_for_last=None):
"""
通过语义判断表格是否为操作项目表(基于第一行内容),并分组。
普通起始标记:组 = [start, ..., next_start - 1]
最后一个起始标记:组 = [start, ..., start + max_following_for_last] (若指定)
"""
groups = []
current_group = None
used_ids = set()
for record in records:
is_start = (
record.get("type") == "table"
and is_operation_table_by_first_tr(record.get("table_body", ""))
)
if is_start:
if current_group is not None:
groups.append(current_group)
used_ids.update(r["id"] for r in current_group)
current_group = [record]
else:
if current_group is not None:
current_group.append(record)
# 处理最后一个组
if current_group is not None:
groups.append(current_group)
used_ids.update(r["id"] for r in current_group)
# —————— 后处理:截断最后一个组(如果需要) ——————
if groups and max_following_for_last is not None:
last_group = groups[-1]
if len(last_group) > max_following_for_last + 1: # +1 是起始记录本身
truncated = last_group[:max_following_for_last + 1]
removed_ids = {r["id"] for r in last_group[max_following_for_last + 1:]}
used_ids -= removed_ids
groups[-1] = truncated
remaining_records = [r for r in records if r["id"] not in used_ids]
return groups, remaining_records
async def extract_entity_relation(chunk):
"""
批量处理一个文档的多个切片(chunks),每个切片调用大模型结构化,并返回全部结果。
"""
final_prompt = prompt_extract_operation_node_rela.replace("text", chunk)
raw_response = await openai_chat_aysnc(final_prompt, timeout=600)
if raw_response is None:
print(f" ⚠️ 切片 API 调用失败")
return []
# 尝试解析 JSON
try:
cleaned = raw_response.strip()
if cleaned.startswith("```json"):
cleaned = cleaned[7:].lstrip()
if cleaned.endswith("```"):
cleaned = cleaned[:-3].rstrip()
json_result = safe_json_loads(cleaned)
return json_result
except Exception as e:
print(f" ⚠️ 切片 JSON 解析失败: {e}")
print(f" 原始响应: {repr(raw_response[:500])}")
return []
async def get_operation_node_relation(input_pdf_path, data, prefix_url="http://192.168.0.46:59085", device="发动机"):
# 1. 初始化数据 ID (如果输入数据还没有ID)
for idx, item in enumerate(data, start=1):
if "id" not in item:
item["id"] = idx
devicename = device
# 2. 数据预处理:获取故障组 and 清洗数据
groups1, remaining_records1 = extract_operation_groups_and_remaining(data)
groups = groups1[1:] if len(groups1) > 1 else []
all_entity_results = []
all_relation_results = []
device_operation = {
"type" : "操作程序",
"properties": {
"名称": f"{devicename}的操作程序",
}
}
if not groups:
print("未找到符合条件的操作项目组,跳过后续处理。")
else:
# 3. 遍历每个维修项目组进行抽取
for i, repair_group in enumerate(groups):
try:
print(f"开始处理第 {i+1} 个操作项目表")
# 获取内容和高亮区域
content, highlight_list = get_content_bbox(repair_group)
if not content:
print(f"第 {i+1} 组:内容为空,跳过")
continue
# ================= 重试逻辑开始 =================
result = None
max_retries = 20
retry_delay = 0.5
highlight_filename, most_common_page,filename = highlight_pdf_file(input_pdf_path,highlight_list)
url = f"{prefix_url}/upload/{highlight_filename}#page={most_common_page}"
for attempt in range(1, max_retries + 2):
# print(content[:500]) # 调试时可打开,避免日志过多
# 1. 调用 API
result = await extract_entity_relation(content)
# 2. 验证结果有效性
is_valid = False
if result is not None and isinstance(result, dict):
# 情况 1: 标准格式 {'entities': [...], 'relationships': [...]}
if "entities" in result and isinstance(result["entities"], list) and len(result["entities"]) > 0:
is_valid = True
# 情况 2: 兼容日志中的格式 {'result': [...]}
elif "result" in result and isinstance(result["result"], list) and len(result["result"]) > 0:
is_valid = True
# 3. 判断是否成功
if is_valid:
if attempt > 1:
print(f" ✅ 第 {attempt} 次尝试成功获取有效数据。")
break
# 4. 如果失败且还有重试机会
if attempt <= max_retries:
reason = "API 返回为空" if result is None else "返回数据格式异常或内容为空"
print(f" ⚠️ 第 {attempt} 次尝试失败:{reason}。等待 {retry_delay} 秒后重试...")
await asyncio.sleep(retry_delay)
else:
print(f" ❌ 经过 {max_retries + 1} 次尝试仍无法获取有效数据,跳过该组。")
result = None
break
# ================= 重试逻辑结束 =================
if result is None:
continue
# 构造知识来源字符串 (此处保持原有逻辑,实际项目中可能需要动态生成真实 URL)
source_data_list = [{
"filename": filename, # 建议替换为真实文件名
"info": content,
"url": url
}]
knowledge_source_str = json.dumps(source_data_list, ensure_ascii=False)
# 兼容不同返回格式
entities = result.get("entities", result.get("result", []))
relationships = result.get("relationships", [])
for entity in entities:
if "properties" not in entity or not isinstance(entity["properties"], dict):
entity["properties"] = {}
entity["properties"]["knowledge_source"] = knowledge_source_str
if entity.get("type") == "操作项目":
entity["properties"]["内容"] = content
# 构建基础关系:设备 -> 操作项目
rela = {
"type": "操作时使用",
"from_entity": f"{devicename}的操作程序",
"to_entity": entity.get("properties", {}).get("名称", "未知实体")
}
all_relation_results.append(rela)
all_entity_results.extend(entities)
all_relation_results.extend(relationships)
except Exception as e:
print(f"处理第 {i+1} 组时发生未预期错误: {e}")
import traceback
traceback.print_exc()
continue
# ================= 【新增】实体和关系去重逻辑 =================
print(f"去重前统计:实体数量={len(all_entity_results)}, 关系数量={len(all_relation_results)}")
# 1. 实体去重
unique_entities = []
seen_entity_keys = set()
for entity in all_entity_results:
props = entity.get("properties", {})
# 定义唯一键:优先使用 "编号",如果没有则使用 "名称"
# 这样可以防止 OT001 出现两次
entity_id = props.get("编号") or props.get("名称")
# 如果连名称都没有,生成一个临时ID避免报错,但这种情况通常视为无效数据
if not entity_id:
entity_id = str(uuid.uuid4())
if entity_id not in seen_entity_keys:
seen_entity_keys.add(entity_id)
unique_entities.append(entity)
else:
# 可选:打印跳过的重复项
# print(f" ⚠️ 跳过重复实体: {entity_id} - {props.get('名称')}")
pass
all_entity_results = unique_entities
# 2. 关系去重
unique_relations = []
seen_relation_keys = set()
for rela in all_relation_results:
# 定义关系唯一键:类型 + 源 + 目标
r_key = (rela.get("type"), rela.get("from_entity"), rela.get("to_entity"))
if r_key not in seen_relation_keys:
seen_relation_keys.add(r_key)
unique_relations.append(rela)
all_relation_results = unique_relations
relation = {'type': '使用', 'from_entity': f'{device}的操作使用', 'to_entity': f'{device}的操作程序'}
all_relation_results.append(relation)
all_entity_results.append(device_operation)
print(f"去重后统计:实体数量={len(all_entity_results)}, 关系数量={len(all_relation_results)}")
# ========================================================
return all_entity_results, all_relation_results
if __name__ == "__main__":
file_path = '/storage01/home/hdf/project/wxkgrag/kgextract/output/output1/122-06A0014-B01001_发动机-操作使用手册_content_list.json'
input_pdf_path = "/storage01/home/hdf/project/wxkgrag/kgextract/output/output1/122-06A0014-B01001_发动机-操作使用手册.pdf"
try:
with open(file_path, 'r', encoding='utf-8') as file:
data = json.load(file)
except Exception as e:
print(f"读取 JSON 文件时出错:{e}")
data = []
all_entity_results, all_relation_results = asyncio.run(
get_operation_node_relation(input_pdf_path, data=data, device="发动机")
)
length_entity = len(all_entity_results)
length_relation = len(all_relation_results)
for ins in all_entity_results:
print(ins)
print(11111111111111111111111)
for ins in all_relation_results:
print(ins)
print(22222222222222222222)
print(f"最终抽取结果:{length_entity}个实体,{length_relation}个关系")