339 lines
13 KiB
Python
339 lines
13 KiB
Python
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 是否为操作项目表,依据是第一行 <tr> 中是否包含 '项目编号' 和 '操作项目'
|
||
"""
|
||
if not isinstance(table_body, str):
|
||
return False
|
||
|
||
# 非贪婪匹配第一个 <tr> ... </tr>
|
||
match = re.search(r'<tr[^>]*>(.*?)</tr>', 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}个关系")
|
||
|
||
|