【AI应用开发数据基建】从非结构化数据到结构化知识的通用转化流程

一、数据准备阶段

数据采集与输入

  • 来源识别:确定数据来源(文档、视频、音频、图片、社交媒体等)

  • 批量处理:设计可扩展的批量处理机制

在批量处理非结构化数据的第一步进行数据情况统计时,以下是需要注意的关键要点:

1. 基础量级统计

  • 文件总量:确认待处理文件的总数量级(百/千/百万级)

  • 存储规模:统计原始数据总占用空间(GB/TB/PB)

  • 类型分布:按扩展名分类统计(PDF/视频/图像等各占比例)

2. 非结构化程度评估

  • 内容可解析性

    • 文本类:可提取文字比例 vs 扫描图像比例

    • 多媒体:是否有字幕/语音转文字可行性

  • 结构特征

    • 是否存在目录/章节/时间戳等内在结构

    • 元数据完整度(作者、创建时间等)

3. 资源需求预估

  • 计算密集型:需要GPU加速处理的文件类型及量级

  • 存储密集型:中间产物(如视频帧)的预估存储需求

  • 时间成本:基于样本测试推算总处理耗时

数据预处理

  • 文件解析

    • 文本类:PDF/Word解析、编码处理

    • 多媒体:视频分帧/音频转文本

    • 图像类:OCR识别、图像增强

  • 内容提取

    • 去除无关内容(页眉页脚、广告等)

    • 保留核心内容结构和元数据

预处理的时候还可以挑选10%-20%左右的典型数据来建立评估集,方便后续优化的时候进行测试。

二、内容理解阶段

混合内容PDF的智能处理流程

1. 内容类型识别分层

  • 2. 多模态协同处理技术

    • 文本层处理

      • 使用OCR引擎(Tesseract/Adobe PDF Extract、GOT-OCR、Paddle OCR、OlmOCR)

      • 保留原始文本布局信息(段落/表格/标题)

    • 视觉层处理

      • 科学图表

        • 使用GPT-4V/Claude-3 Opus解析:

        prompt = "将此学术图表转化为:1) 图表类型说明 2) 横纵坐标含义 3) 关键数据趋势描述"
      • 装置示意图

        • 采用LLaVA-1.6等视觉模型:

        prompt = "描述此机械装置的:1) 核心组件 2) 工作原理 3) 物质流动方向"
      • 数学公式

        • MathPix API + LaTeX解析

        这种处理方式相比传统OCR能多提取30-50%的隐含知识,特别适合学术文献、技术手册等专业文档的深度数字化。实际实施时需要根据领域特点调整多模态prompt的设计。

三、结构化存储与应用阶段

混合存储架构

┌─────────────────┐
│  结构化数据库   │←──SQL/NoSQL
├─────────────────┤
│  向量数据库     │←──FAISS/Milvus
├─────────────────┤
│  图数据库       │←──Neo4j/JanusGraph
└─────────────────┘

采用混合存储方案(如结合结构化数据库、向量数据库和图数据库)是为了应对非结构化数据转化后知识的多维特性不同使用场景的需求


1. 数据类型与访问模式的多样性

数据类型典型特征最优存储方案应用场景示例
元数据结构化字段(作者、日期等)关系型数据库(MySQL/PostgreSQL)精确查询、统计分析
内容向量高维嵌入向量(768-1536维)向量数据库(FAISS/Milvus)语义搜索、相似推荐
关系网络实体-关系-实体三元组图数据库(Neo4j/JanusGraph)关联推理、路径分析

案例
一篇学术论文转化后:

  • SQL库存储 {标题,作者,发表年份}

  • 向量库存储 摘要文本嵌入向量

  • 图库存储 作者-研究领域-方法论 关系网


2. 性能与效率的平衡

  • 结构化查询

    SELECT * FROM papers WHERE year > 2020 AND author = "李华" 

    → 关系型数据库比向量库快100倍

  • 语义搜索

    db.similarity_search("量子计算的最新进展", k=5)

    → 向量数据库比SQL快1000倍(对于近似最近邻搜索)

  • 关联推理

    MATCH (a:Author)-[r:COLLABORATED_WITH]->(b) 
    WHERE a.name = "王强" RETURN b

    → 图数据库比关系库快100倍(对于深度遍历

典型需要深度遍历的问题场景:

社交网络分析

  • 场景:查找某人的N度人脉(例如"朋友的朋友的朋友")。

  • 深度遍历

    • 关系库需要多次JOIN(性能随深度指数下降),而图库(如Neo4j)直接沿边遍历,复杂度仅为O(深度)。


欺诈检测与反洗钱

  • 场景:识别复杂资金环或网状交易路径(例如5层转账链路)。

  • 深度遍历

    • 关系库需递归CTE或多表连接,图库通过(A)-[转账]->(B)-[转账]->(C)...直接追踪路径。


知识图谱与推理

  • 场景:医学知识库中查找"药物A→副作用B→禁忌症C→替代药物D"的关联链。

  • 深度遍历

    • 图库通过属性图快速跳转,关系库需多次自连接或中间表查询。


推荐系统

  • 场景:基于协同过滤的"用户喜欢A→A相似物品B→喜欢B的用户也喜欢C"的推荐。

  • 深度遍历

    • 图库直接遍历用户-物品-用户网络,关系库需多次聚合和JOIN。


供应链与物流路径优化

  • 场景:查找供应商的N级上游依赖(例如"零件厂商→组件厂商→整车厂商")。

  • 深度遍历

    • 图库支持可变长度路径查询(如Neo4j的[:SUPPLIES*1..5]),关系库需动态生成SQL。


3. 知识完整性的需求

混合存储确保三种知识表达不丢失

  1. 表层知识(What)→ 结构化数据库
    "这篇论文发表于Nature 2023年"

  2. 语义知识(Meaning)→ 向量数据库
    "该研究证明了室温超导的可能性"

  3. 关联知识(Why/How)→ 图数据库
    "该方法借鉴了2015年张团队的实验设计"


混合架构实施建议

四、质量保障机制

语义理解环节的优化策略

1. 提示词工程黄金法则

需要掌握一些提示词的技巧,来提高信息提取的效果

  • 结构化提取模板

    prompt = """请严格按以下结构提取信息:
    {
      "核心实体": [{
        "名称": "",
        "类型": "人物/地点/技术",
        "属性": {"key1":"value1", "key2":"value2"}
      }],
      "关键关系": [{
        "主体": "实体1",
        "客体": "实体2",
        "关系类型": "影响/依赖/包含",
        "证据文本": "原文引用"
      }],
      "时间线索": [{
        "事件": "",
        "时间点": "",
        "置信度": "高/中/低"
      }]
    }"""

2. 动态提示调整

  • 上下文感知提示

    if "学术论文" in document_metadata:
        prompt += "\n请特别关注METHODOLOGY部分的实验参数"
    elif "技术专利" in document_metadata:
        prompt += "\n重点提取权利要求书中的技术特征"

LLM API选型矩阵

模型特性Gemini-1.5-ProGPT-4-TurboClaude-3-Opus适用场景建议
上下文窗口1M tokens128K200K长文献/视频转录
结构化输出能力★★★★☆★★★★☆★★★★★复杂JSON生成
多模态支持★★★★★★★★☆☆★★★★☆图文混合解析
中文处理★★★★☆★★★☆☆★★★☆☆中文技术文档
价格($/1M输入)$7.00$10.00$15.00成本敏感型项目

实践建议

  • 长文本处理流水线


例1:非结构化文档(PDF/视频)转化为结构化知识库

将一些PDF格式的讲座PPT、MP4格式视频文件等学习资料,结构化提取其中的信息处理到数据库、向量库、Neo4j,涉及了多模态非结构化数据处理

1. PDF处理流程

2. 视频处理流程

关键技术创新点

  1. 多模态内容理解

    • 同时处理文本和视觉内容

    • 使用Gemini模型解析:

      response = gemini_model.generate_content([
          {"text": "分析此图表..."}, 
          {"image": base64_data}
      ])
  2. 混合知识表示

    • 结构化元数据(SQLite)

    • 语义向量(FAISS)

    • 关系网络(Neo4j)

    # Neo4j关系创建示例
    session.run("""
        MERGE (r:Resource {name: $name})
        MERGE (c:Capability {name: $cap})
        MERGE (r)-[:HAS_CAPABILITY]->(c)
    """, name=doc_name, cap=capability)
  3. 动态质量控制系统

    • 页面级分析校验

      • 文档级摘要验证

      • 跨模态一致性检查

    def _analyze_text_and_match_capabilities(text):
        # 强制使用预定义标签
        capabilities = load_predefined_tags()
        ...

工程实践亮点

  1. 文件处理优化

    • PDF分页处理避免内存溢出

    images = convert_from_path(pdf_path, dpi=200)  # 控制分辨率
    • 视频分段处理

    ffmpeg -ss {start} -i input.mp4 -t {duration} -c copy chunk.mp4
  2. 异常处理机制

    try:
        video_file = genai.upload_file(chunk_path)
        while video_file.state == "PROCESSING":
            time.sleep(5)
    except Exception as e:
        logger.error(f"分段处理失败: {e}")
    finally:
        os.unlink(chunk_path)  # 清理临时文件
  3. 性能监控

    start_time = time.time()
    # 处理过程...
    print(f"处理耗时: {time.time()-start_time:.2f}秒")

 源代码:

import os
import json
import numpy as np
import faiss
from PIL import Image
from pdf2image import convert_from_path
import torch
import torchvision.transforms as transforms
from transformers import AutoProcessor, AutoModelForZeroShotObjectDetection, AutoTokenizer, AutoModel
from typing import List, Dict, Tuple
from langchain_anthropic import ChatAnthropic
from langchain.prompts import PromptTemplate
from openai import OpenAI
import google.generativeai as genai # 导入Google的SDK
import sqlite3
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores import FAISS
import time
import base64
from io import BytesIO
import tempfile
import subprocess
from app.services.db_resource import ResourceServe
# from app.services.neo4j_crud import save_to_neo4j  # 假设这是neo4j的接口函数
from app.utils.configs import (
    RESOURCE_VECTOR_DIR,
    LEARNING_RESOURCES_FOLDER,
    EMBEDDING_MODEL_PATH,
    LEVEL_TWO_JSON_PATH,
    OPENAI_API_KEY,
    OPENAI_API_BASE,
    LLF_KNOWLEDGE_BASES_FOLDER
)
from neo4j import GraphDatabase
import datetime
import ffmpeg
from app.utils.llm_utils import get_llm_response
os.environ['HF_DATASETS_OFFLINE'] = '1'
os.environ['TRANSFORMERS_OFFLINE'] = '1'
class ResourceVectorDB:
    def __init__(self, vector_dir, vector_dim=1792, google_api_key: str = None):
        self.db_path = vector_dir
        self.device = "cuda" # 假设你的HuggingFace Embeddings仍需要cuda
        self.vector_dim = vector_dim

        if google_api_key is None:
            raise ValueError("Google API Key must be provided.")
        genai.configure(api_key=google_api_key)

        # 初始化模型 (Embedding模型保持不变)
        self.embeddings = HuggingFaceEmbeddings(
            model_name="/data/hyq/huggingface/models--lier007--xiaobu-embedding-v2/snapshots/1912f2e59a5c2ef802a471d735a38702a5c9485e",
            model_kwargs={'device': self.device}
        )

        os.makedirs(self.db_path, exist_ok=True)
        self.entire_path = os.path.join(vector_dir, "entire_doc")
        self.detail_path = os.path.join(vector_dir, "detail_doc")
        # entire_store
        if os.path.exists(self.entire_path) and os.listdir(self.entire_path):
            try:
                self.entire_store = FAISS.load_local(
                    self.entire_path,
                    self.embeddings,
                    allow_dangerous_deserialization=True
                )
                print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] FAISS向量库从 {self.entire_path} 加载成功。")
            except Exception as e:
                print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 从 {self.entire_path} 加载FAISS向量库失败: {e}. 创建新的库。")
                self._create_empty_faiss_store()
        else:
            self._create_empty_faiss_store()
        # detail_store
        if os.path.exists(self.detail_path) and os.listdir(self.detail_path):
            try:
                self.detail_store = FAISS.load_local(
                    self.detail_path,
                    self.embeddings,
                    allow_dangerous_deserialization=True
                )
                print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] FAISS detail向量库从 {self.detail_path} 加载成功。")
            except Exception as e:
                print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 从 {self.detail_path} 加载FAISS detail向量库失败: {e}. 创建新的库。")
                self.detail_store = FAISS.from_texts(
                    texts=["初始化向量库占位文本"],
                    embedding=self.embeddings,
                    metadatas=[{"id": "init_placeholder", "type": "init"}]
                )
                self.detail_store.save_local(self.detail_path)
        else:
            self.detail_store = FAISS.from_texts(
                texts=["初始化向量库占位文本"],
                embedding=self.embeddings,
                metadatas=[{"id": "init_placeholder", "type": "init"}]
            )
            self.detail_store.save_local(self.detail_path)

        # 初始化 Gemini 模型客户端
        self.gemini_model = genai.GenerativeModel('gemini-1.5-pro-latest') # 或 'gemini-1.5-pro'

    def _create_empty_faiss_store(self):
        print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 创建新的空FAISS向量库。")
        # FAISS.from_texts 需要至少一个文本,但我们可以用一个占位符,后续添加真实数据
        # 或者,如果你的FAISS版本支持,可以创建一个空的索引,但这通常更复杂
        # 一个简单的方法是初始化后立即保存一个空的(或带一个虚拟条目的)
        # 这里我们先不创建实际的FAISS对象,而是在第一次add_texts时创建它
        # 或者,像你原来那样,用一个初始化文本
        self.entire_store = FAISS.from_texts(
            texts=["初始化向量库占位文本"],
            embedding=self.embeddings,
            metadatas=[{"id": "init_placeholder", "type": "init"}]
        )
        self.entire_store.save_local(self.entire_path)

    def extract_lecturer_from_filename(self, filename):
        """
        使用LLM从文件名中提取讲师姓名
        """
        prompt = f"""
        请从以下文件名中提取讲师姓名。如果找不到讲师姓名,请返回"未设置"。
        只需要返回讲师姓名,不要其他解释。
        
        文件名: {filename}
        """
        
        try:
            # 直接获取响应文本
            lecturer = get_llm_response(prompt)
            
            # 如果响应为空或包含"未设置"相关文字,返回默认值
            if not lecturer or "未设置" in lecturer or "找不到" in lecturer:
                return "未设置"
            return lecturer
        except Exception as e:
            print(f"提取讲师姓名时出错: {str(e)}")
            return "未设置"

    def add_resource(self, file_path: str):
        """根据文件类型自动分发处理"""
        ext = os.path.splitext(file_path)[-1].lower()
        if ext == ".pdf":
            return self.add_pdf(file_path)
        elif ext in [".mp4", ".mov", ".avi", ".mkv"]:
            return self.add_video(file_path)
        else:
            raise ValueError(f"暂不支持的文件类型: {ext}")
    
    def add_pdf(self, pdf_path: str) -> dict:
        """Add PDF to the vector database"""
        if not os.path.exists(pdf_path):
            raise FileNotFoundError(f"PDF file not found: {pdf_path}")
        
        start_time = time.time()
        print(f"\n[{time.strftime('%Y-%m-%d %H:%M:%S')}] 开始处理PDF文件: {pdf_path}")
        
        # 添加当前PDF路径的记录
        self.current_pdf_path = pdf_path
        pdf_name = os.path.basename(pdf_path)
        upload_date = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        
        # Convert PDF to images
        print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 正在将PDF转换为图像...")
        images = self._pdf_to_images(pdf_path)
        print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] PDF转换完成,共 {len(images)} 页")
        
        all_page_summaries = []
        all_capabilities = set()
        
        for page_num, image in enumerate(images, start=1):
            print(f"\n[{time.strftime('%Y-%m-%d %H:%M:%S')}] 正在处理第 {page_num}/{len(images)} 页...")
            
            # 获取页面分析结果
            page_start_time = time.time()
            page_analysis = self._analyze_page_content(image)
            page_time = time.time() - page_start_time
            
            print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 第 {page_num} 页分析完成,耗时: {page_time:.2f}秒")
            print(f"页面摘要: {page_analysis['summary'][:100]}...")
            print(f"内容类型: {page_analysis['content_type']}")
            print(f"识别到的能力标签: {', '.join(page_analysis['capabilities'])}")
            
            # 收集每页的摘要和能力标签
            all_page_summaries.append(f"第{page_num}页: {page_analysis['summary']}")
            all_capabilities.update(page_analysis['capabilities'])
            
            # Create document record
            doc = {
                "pdf_name": pdf_name,
                "page_num": page_num,
                "summary": page_analysis["summary"],
                "content_type": page_analysis["content_type"],
                "key_points": page_analysis["key_points"],
                "visual_elements": page_analysis["visual_elements"],
                "capabilities": page_analysis["capabilities"]
            }
            
            # 使用 detail_store 向量库添加页面内容
            self.detail_store.add_texts(
                texts=[page_analysis['summary']],
                metadatas=[doc]
            )
        
        # 生成整个PDF的总结
        print(f"\n[{time.strftime('%Y-%m-%d %H:%M:%S')}] 正在生成PDF整体总结...")
        summary_start_time = time.time()
        pdf_summary = self._generate_pdf_summary(pdf_name, all_page_summaries, list(all_capabilities))
        summary_time = time.time() - summary_start_time
        
        print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] PDF总结生成完成,耗时: {summary_time:.2f}秒")
        
        # 创建PDF总结文档记录
        summary_doc = {
            "pdf_name": pdf_name,
            "summary": pdf_summary["summary"],
            "key_points": pdf_summary["key_points"],
            "capabilities": pdf_summary["capabilities"]
        }
        
        # 使用 entire_store 向量库添加PDF总结
        self.entire_store.add_texts(
            texts=[pdf_summary["summary"]],
            metadatas=[summary_doc]
        )
        
        # 保存两个向量库
        self.detail_store.save_local(self.detail_path)
        self.entire_store.save_local(self.entire_path)
        
        # 从文件名中提取讲师姓名
        lecturer = self.extract_lecturer_from_filename(pdf_name)
        print("讲师姓名为:",lecturer)
        
        # 处理其他数据
        summary = pdf_summary["summary"]
        key_points_str = json.dumps(pdf_summary["key_points"], ensure_ascii=False)
        capabilities_str = json.dumps(pdf_summary["capabilities"], ensure_ascii=False)
        
        # 组装 result
        page_count = len(images)
        result = {
            "name": pdf_name,
            "type": "pdf",
            "upload_date": upload_date,
            "overall_summary": pdf_summary["summary"],
            "duration": None,
            "page_count": page_count,
            "capabilities": pdf_summary["capabilities"],
            "lecturer": lecturer
        }
        print(result)

         # 保存到Neo4j
        # self.save_to_neo4j(result)
        print("neo4j保存成功")

         # 调用数据库服务添加资源,包含讲师信息
        ResourceServe.add_resource(
            pdf_name=pdf_name,
            summary=summary,
            capabilities=capabilities_str,
            lecturer=lecturer,  # 添加讲师信息
            points=0  # 默认积分为0
        )
        
        # 打印PDF总结
        print("\nPDF总结:")
        print(f"摘要: {pdf_summary['summary']}")
        print(f"关键要点: {', '.join(pdf_summary['key_points'])}")
        print(f"综合能力标签: {', '.join(pdf_summary['capabilities'])}")
        
        total_time = time.time() - start_time
        print(f"\n[{time.strftime('%Y-%m-%d %H:%M:%S')}] PDF处理完成,总耗时: {total_time:.2f}秒")
        
        return {
            "name": pdf_name,
            "type": "pdf",
            "upload_date": upload_date,
            "overall_summary": pdf_summary["summary"],
            "duration": None,
            "page_count": page_count,
            "capabilities": pdf_summary["capabilities"],
            "lecturer": lecturer
        }

    def add_video(self, video_path: str) -> dict:
        """对视频每5分钟切块,分别理解后再整体总结"""
        if not os.path.exists(video_path):
            raise FileNotFoundError(f"视频文件未找到: {video_path}")

        start_time = time.time()
        print(f"\n[{time.strftime('%Y-%m-%d %H:%M:%S')}] 开始处理视频文件: {video_path}")

        self.current_video_path = video_path
        video_name = os.path.basename(video_path)

        # 1. 切割视频
        print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 正在切割视频...")
        chunk_infos = self.split_video_to_chunks(video_path, chunk_duration=600)
        print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 视频切割完成,共 {len(chunk_infos)} 段。")

        chunk_summaries = []
        chunk_key_points = []
        chunk_capabilities = []

        # 2. 逐段理解
        for idx, chunk_info in enumerate(chunk_infos):
            chunk_path = chunk_info["path"]
            start = chunk_info["start"]
            end = chunk_info["end"]
            print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 处理第{idx+1}段: {chunk_path}")
            try:
                # 上传并理解每个chunk
                video_file = genai.upload_file(path=chunk_path)
                while video_file.state.name == "PROCESSING":
                    time.sleep(5)
                    video_file = genai.get_file(video_file.name)
                if video_file.state.name == "FAILED":
                    print(f"分段{idx+1}处理失败")
                    continue

                # 读取能力标签
                with open("/data/hyq/code/llf/process/leveltwo_data.json", "r", encoding="utf-8") as f:
                    capabilities = json.load(f)
                capabilities_str = json.dumps(capabilities, ensure_ascii=False, indent=2)

                prompt = f"""请对以下视频片段内容进行详细分析,要求:
1. 给出一段不超过100字的内容概述
2. 总结2-3个核心要点
3. 能力标签(capabilities)字段**必须严格从下方预定义标签列表中选择,不能自创、不能组合、不能修改**,最多选2个,直接返回标签原文。
4. 直接返回如下JSON格式,不要包含其他说明:
{{
    "summary": "内容概述",
    "key_points": ["要点1", "要点2"],
    "capabilities": ["能力标签1"]
}}
预定义能力标签列表(只能从中选择,不能自创、不能组合、不能修改):
{capabilities_str}
"""
                response = self.gemini_model.generate_content([prompt, video_file])
                result_text = response.text.strip()
                # 解析JSON
                if result_text.startswith("```json"):
                    result_text = result_text[len("```json"):].strip()
                if result_text.endswith("```"):
                    result_text = result_text[:-len("```")].strip()
                start_idx = result_text.find('{')
                end_idx = result_text.rfind('}')
                if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
                    json_str = result_text[start_idx : end_idx+1]
                    result = json.loads(json_str)
                else:
                    print(f"分段{idx+1}未能解析JSON")
                    continue

                chunk_summaries.append(result["summary"])
                chunk_key_points.extend(result["key_points"])
                chunk_capabilities.extend(result["capabilities"])

                # 先定义 summary_doc
                summary_doc = {
                    "pdf_name": f"{video_name}_chunk_{idx+1}",
                    "summary": result["summary"],
                    "key_points": result["key_points"],
                    "capabilities": result["capabilities"],
                    "source_type": "video_chunk",
                    "start_time": start,   # 起始秒数
                    "end_time": end       # 结束秒数
                }
                # 存入detail_store
                if hasattr(self, 'detail_store'):
                    self.detail_store.add_texts(
                        texts=[result["summary"]],
                        metadatas=[summary_doc]
                    )
                    self.detail_store.save_local(self.detail_path)

                # 删除远程文件
                genai.delete_file(video_file.name)
            except Exception as e:
                print(f"分段{idx+1}处理异常: {e}")
            finally:
                # 删除本地临时文件
                if os.path.exists(chunk_path):
                    os.unlink(chunk_path)

        # 3. 对所有分段摘要做整体总结
        print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 开始整体总结...")
        all_summary_text = "\n".join(chunk_summaries)
        with open("/data/hyq/code/llf/process/leveltwo_data.json", "r", encoding="utf-8") as f:
            capabilities = json.load(f)
        capabilities_str = json.dumps(capabilities, ensure_ascii=False, indent=2)
        overall_prompt = f"""以下是视频各片段的内容概述,请基于这些内容,完成如下任务:
1. 给出一段不超过200字的整体内容概述
2. 总结3-5个核心要点
3. 能力标签(capabilities)字段**必须严格从下方预定义标签列表中选择,不能自创、不能组合、不能修改**,最多选4个,直接返回标签原文。
4. 直接返回如下JSON格式,不要包含其他说明:
{{
    "summary": "整体内容概述",
    "key_points": ["要点1", "要点2", "要点3"],
    "capabilities": ["能力标签1", "能力标签2"]
}}
预定义能力标签列表(只能从中选择,不能自创、不能组合、不能修改):
{capabilities_str}

各片段内容概述如下:
{all_summary_text}
"""
        response = self.gemini_model.generate_content([overall_prompt])
        result_text = response.text.strip()
        if result_text.startswith("```json"):
            result_text = result_text[len("```json"):].strip()
        if result_text.endswith("```"):
            result_text = result_text[:-len("```")].strip()
        start_idx = result_text.find('{')
        end_idx = result_text.rfind('}')
        if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
            json_str = result_text[start_idx : end_idx+1]
            result = json.loads(json_str)
        else:
            raise ValueError("整体总结未能解析JSON")

        # 获取视频时长
        probe = ffmpeg.probe(video_path)
        duration = float(probe['format']['duration'])
        upload_date = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        result_dict = {
            "name": video_name,
            "type": "video",
            "upload_date": upload_date,
            "overall_summary": result["summary"],
            "duration": duration,
            "page_count": None,
            "capabilities": result["capabilities"]
        }

        # self.save_to_database(result_dict) 已经弃用

        total_time = time.time() - start_time
        print(f"\n[{time.strftime('%Y-%m-%d %H:%M:%S')}] 视频处理完成: {video_name},总耗时: {total_time:.2f}秒")
        print(f"摘要: {result['summary']}")
        print(f"关键要点: {', '.join(result['key_points'])}")
        print(f"能力标签: {', '.join(result['capabilities'])}")

        # 从文件名中提取讲师姓名
        lecturer = self.extract_lecturer_from_filename(video_name)
        # 调用数据库服务添加资源,包含讲师信息
        ResourceServe.add_resource(
            pdf_name=video_name,
            summary=result["summary"],
            capabilities=result["capabilities"],
            lecturer=lecturer,  # 添加讲师信息
            points=0  # 默认积分为0
        )
        # 保存到Neo4j
        self.save_to_neo4j({
            "name": video_name,
            "summary": result["summary"],
            "key_points": result["key_points"],
            "capabilities": result["capabilities"]
        })

        return {
            "name": video_name,
            "type": "video",
            "upload_date": upload_date,
            "overall_summary": result["summary"],
            "duration": duration,
            "page_count": None,
            "capabilities": result["capabilities"],
            "lecturer": lecturer
        }

    ########### 即将弃用 ##################
    def save_to_database(self, result: dict, db_path: str = ""):
        if not db_path:
            db_path = LLF_KNOWLEDGE_BASES_FOLDER
        try:
            conn = sqlite3.connect(db_path)
            cursor = conn.cursor()
            # 统一获取资源名
            resource_name = os.path.basename(self.current_pdf_path) if hasattr(self, 'current_pdf_path') else (
                os.path.basename(self.current_video_path) if hasattr(self, 'current_video_path') else ''
            )
            summary = result.get('overall_summary', '')
            key_points_str = json.dumps(result.get('key_points', []), ensure_ascii=False)
            capabilities_str = json.dumps(result.get('capabilities', []), ensure_ascii=False)

            cursor.execute("SELECT id FROM resource WHERE pdf_name = ?", (resource_name,))
            existing_record = cursor.fetchone()
            if existing_record:
                print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 更新数据库记录: {resource_name}")
                cursor.execute("""
                    UPDATE resource
                    SET summary = ?, capabilities = ?
                    WHERE pdf_name = ?
                """, (summary, capabilities_str, resource_name))
            else:
                print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 插入新数据库记录: {resource_name}")
                cursor.execute("""
                    INSERT INTO resource (pdf_name, summary, capabilities)
                    VALUES (?, ?, ?)
                """, (resource_name, summary, capabilities_str))
            conn.commit()
        except sqlite3.Error as e:
            print(f"数据库操作时发生错误: {e}")
        finally:
            if conn:
                conn.close()
        self.save_to_neo4j(result)

    def split_video_to_chunks(self, video_path, chunk_duration=300):
        """
        使用FFmpeg将视频按chunk_duration(秒)切割,返回每个片段的临时文件路径列表
        """
        try:
            probe = ffmpeg.probe(video_path)
            duration = float(probe['format']['duration'])
        except Exception as e:
            raise RuntimeError(f"获取视频时长失败: {e}")

        chunk_files = []
        for start in range(0, int(duration), chunk_duration):
            end = min(start + chunk_duration, int(duration))
            # 创建临时文件
            temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.mp4')
            chunk_path = temp_file.name
            temp_file.close()  # 让ffmpeg可以写入
            cmd = [
                "ffmpeg",
                "-y",
                "-ss", str(start),
                "-i", video_path,
                "-t", str(end - start),
                "-c", "copy",
                chunk_path
            ]
            print(f"正在切割: {chunk_path},时间段: {start}-{end}")
            result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            if result.returncode != 0:
                print(f"FFmpeg切割失败: {result.stderr.decode()}")
                os.unlink(chunk_path)
                continue
            chunk_files.append({
                "path": chunk_path,
                "start": start,
                "end": end
            })
        return chunk_files

    def _pdf_to_images(self, pdf_path: str, dpi: int = 200):
        """
        将PDF文件转换为图片列表(每页一张PIL Image)
        """
        return convert_from_path(pdf_path, dpi=dpi)

    def _analyze_page_content(self, image):
        """使用视觉理解LLM分析页面内容"""
        try:
            print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 正在将图像转换为base64...")
            image_base64 = self.image_to_base64(image)
            print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 正在使用多模态模型解析图像内容...")
            # 这里用 Gemini 生成内容
            response = self.gemini_model.generate_content([
                {
                    "role": "user",
                    "parts": [
                        {
                            "text": """请详细描述这个PDF页面的内容,包括:
1. 页面的整体布局和结构
2. 文字内容、图表、图片等视觉元素
3. 关键信息的位置和重要性

请直接描述内容,不需要特定格式。"""
                        },
                        {
                            "inline_data": {
                                "mime_type": "image/png",
                                "data": image_base64
                            }
                        }
                    ]
                }
            ])
            page_content = response.text.strip()
            return self._analyze_text_and_match_capabilities(page_content)
        except Exception as e:
            print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] LLM分析失败: {str(e)}")
            return {
                "summary": "分析失败",
                "content_type": "error",
                "key_points": ["文本分析失败"],
                "visual_elements": [],
                "capabilities": []
            }

    def _analyze_text_and_match_capabilities(self, text_content: str):
        """分析文本内容并匹配能力标签"""
        try:
            with open("/data/hyq/code/llf/process/leveltwo_data.json", "r", encoding="utf-8") as f:
                capabilities = json.load(f)
            # 用 Gemini 生成内容
            prompt = f"""你是一个专业的文档分析专家。请分析提供的文本内容,并返回JSON格式的分析结果。
返回格式要求:
{{
    "summary": "内容的简短描述(不超过100字)",
    "content_type": "内容类型(如:图表说明、技术原理、实验数据等)",
    "key_points": ["要点1", "要点2", "要点3"],
    "visual_elements": ["文档中提到的视觉元素列表"],
    "capabilities": ["从能力标签中选择的1-3个最相关标签"]
}}
重要提示:
1. capabilities字段必须严格从以下预定义标签列表中选择,不允许添加、修改或组合标签
2. 如果找不到完全匹配的标签,请选择最接近的标签
3. 不要对标签进行任何修改或扩展
4. 最多选择3个最相关的标签

预定义标签列表:
{json.dumps(capabilities, ensure_ascii=False, indent=2)}

文本内容:
{text_content}
"""
            response = self.gemini_model.generate_content([prompt])
            result_text = response.text.strip()
            # 解析JSON
            start = result_text.find('{')
            end = result_text.rfind('}') + 1
            if start != -1 and end != 0:
                json_str = result_text[start:end]
                result = json.loads(json_str)
            else:
                result = {
                    "summary": result_text[:100] if len(result_text) > 100 else result_text,
                    "content_type": "auto_generated",
                    "key_points": [result_text[:100]],
                    "visual_elements": [],
                    "capabilities": []
                }
            return result
        except Exception as e:
            print(f"文本分析失败: {str(e)}")
            return {
                "summary": "分析失败",
                "content_type": "error",
                "key_points": ["文本分析失败"],
                "visual_elements": [],
                "capabilities": []
            }

    def _generate_pdf_summary(self, pdf_name: str, page_summaries, all_capabilities):
        """生成整个PDF的总结"""
        try:
            prompt = (
                f'这是一个名为"{pdf_name}"的PDF文档的各页面摘要,请分析这些内容并生成整个PDF的总结。\n\n'
                f'各页面摘要:\n' + '\n'.join(page_summaries) + '\n\n'
                f'已识别的能力标签:\n' + ', '.join(all_capabilities) + '\n\n'
                '请生成一个总结,包含以下内容:\n'
                '1. 一段不超过200字的整体内容概述\n'
                '2. 3-5个核心要点\n'
                '3. 从已识别的能力标签中选择2-4个最能代表整个PDF内容的标签\n\n'
                '直接返回如下JSON格式,不要包含任何其他标记或说明:\n'
                '{\n'
                '    "summary": "在这里填写整体内容概述",\n'
                '    "key_points": [\n'
                '        "在这里填写核心要点1",\n'
                '        "在这里填写核心要点2",\n'
                '        "在这里填写核心要点3"\n'
                '    ],\n'
                '    "capabilities": [\n'
                '        "在这里填写能力标签1",\n'
                '        "在这里填写能力标签2"\n'
                '    ]\n'
                '}'
            )
            response = self.gemini_model.generate_content([prompt])
            result_text = response.text.strip()
            if result_text.startswith('```json'):
                result_text = result_text.replace('```json', '', 1)
            if result_text.endswith('```'):
                result_text = result_text.rsplit('```', 1)[0]
            result_text = result_text.strip()
            result = json.loads(result_text)
            if all(k in result for k in ["summary", "key_points", "capabilities"]):
                return result
            raise ValueError("Missing required fields in JSON structure")
        except Exception as e:
            print(f"生成PDF总结时出错: {str(e)}")
            return {
                "summary": f"《{pdf_name}》总结生成失败",
                "key_points": ["总结生成失败"],
                "capabilities": all_capabilities[:3]
            }

    @staticmethod
    def image_to_base64(image):
        """将PIL Image转换为base64编码"""
        buffered = BytesIO()
        image.save(buffered, format="PNG")
        return base64.b64encode(buffered.getvalue()).decode()

    def save_to_neo4j(self, result: dict):
        # 连接参数请根据实际情况修改
        uri = "bolt://localhost:7687"
        user = "neo4j"
        password = "123456"

        driver = GraphDatabase.driver(uri, auth=(user, password))
        resource_name = result.get('name', '')  # 资源名
        resource_type = result.get('type', '')
        overall_summary = result.get('overall_summary', '')
        upload_date = result.get('upload_date', '')
        duration = result.get('duration', None)
        page_count = result.get('page_count', None)
        capabilities = result.get('capabilities', [])

        with driver.session() as session:
            # 创建资源节点
            session.run(
                """
                MERGE (r:Resource {
                    name: $name,
                    type: $type
                })
                SET r.overall_summary = $overall_summary,
                    r.upload_date = $upload_date,
                    r.duration = $duration,
                    r.page_count = $page_count
                """,
                name=resource_name,
                type=resource_type,
                overall_summary=overall_summary,
                upload_date=upload_date,
                duration=duration,
                page_count=page_count
            )

            # 创建能力节点并建立关系
            for cap in capabilities:
                cap_name = cap.get('name') if isinstance(cap, dict) else cap
                session.run(
                    """
                    MERGE (c:Capability {name: $cap_name})
                    WITH c
                    MATCH (r:Resource {name: $resource_name})
                    MERGE (r)-[:HAS_CAPABILITY]->(c)
                    """,
                    cap_name=cap_name,
                    resource_name=resource_name
                )
        driver.close()

if __name__ == "__main__":
    print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 程序启动")
    vector_dir = "/data/hyq/code/llf/pdf_vectors"
    resources_dir = "/data/hyq/code/llf/process/LearningResource"
    
    print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 正在初始化向量库...")
    vector_db = ResourceVectorDB(vector_dir=vector_dir, vector_dim=1792)
    
    # 获取resources目录下所有PDF文件
    pdf_files = [f for f in os.listdir(resources_dir) if f.lower().endswith('.pdf')]
    print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 找到 {len(pdf_files)} 个PDF文件待处理")
    
    # 处理每个PDF文件
    for pdf_file in pdf_files:
        pdf_path = os.path.join(resources_dir, pdf_file)
        print(f"\n开始处理PDF文件: {pdf_file}")
        try:
            result = vector_db.add_resource(pdf_path)
            print(f"处理结果: {result}")
        except Exception as e:
            print(f"处理文件 {pdf_file} 时发生错误: {str(e)}")
    
    print("\n所有PDF文件处理完成!")
    
    # 测试搜索功能
    text_query = "寻找加热相关"
    print(f"\n文本搜索查询: '{text_query}'")
    
    # 搜索PDF总结
    print("\nPDF总结搜索结果:")
    summary_results = vector_db.search_resources(text_query)
    for res in summary_results:
        print(f"\n文档: {res['pdf']}")
        print(f"相似度分数: {res['score']:.4f}")
        print(f"摘要: {res['summary']}")
        print(f"关键要点: {', '.join(res['key_points'])}")
        print(f"相关能力: {', '.join(res['capabilities'])}")
        print("-" * 80)
    
    # 搜索具体页面内容
    print("\n页面内容搜索结果:")
    page_results = vector_db.search_pages(text_query)
    for res in page_results:
        print(f"\n文档: {res['pdf']}")
        print(f"页码: {res['page_num']}")
        print(f"相似度分数: {res['score']:.4f}")
        print(f"摘要: {res['summary']}")
        print(f"内容类型: {res['content_type']}")
        print(f"关键要点: {', '.join(res['key_points'])}")
        print(f"视觉元素: {', '.join(res['visual_elements'])}")
        print(f"相关能力: {', '.join(res['capabilities'])}")
        print("-" * 80)
        print("-" * 80)
    

例2:简历PDF转化为结构化知识库

简历PDF与PPT课件的差异在于,简历基本就是文本信息,不过是特殊的文本比如艺术字,因此需要使用OCR工具来识别文字,但不需要理解。

系统架构设计

核心处理流程

  1. PDF预处理阶段

    • 使用GOT-OCR进行多GPU并行转换

    mp.spawn(process_ocr, args=(args,), nprocs=args.gpus_per_node)
  2. Markdown增强处理

    • 修复Mathpix特有的标记问题

    • 使用GPT-4进行语义规范化:

    convert_mathpix_to_markdown("##[1] 姓名...") → "## 基本信息\n姓名:张三"
  3. 结构化提取关键

    • 三级分类体系:

      • 外部简历(含教育/工作经历)

      • 伙伴工作经历

      • 其他内容

    • 动态JSON模板匹配:

    if "enn_experience" in data: 
        template = "伙伴工作经历模板"
  4. 双存储引擎

    • SQLite:存储精确结构化数据

      INSERT INTO ExternalResume 
      (name, educational_background) VALUES 
      ('张三', '2018-2022 北京大学 计算机')
    • FAISS:存储语义向量

      FAISS.from_documents(docs, embeddings)

关键技术亮点

  1. 多模态处理流水线

    • PDF→文本→结构化JSON→向量化

    • 各阶段质量检查点:

      try:
          pdf_to_markdown()
      except Exception as e:
          logger.error(f"PDF转换失败: {e}")
          clear_gpu_memory()
  2. 动态内存管理

    • GPU显存监控与释放:

    torch.cuda.empty_cache()
    free_mem = torch.cuda.memory_reserved() - torch.cuda.memory_allocated()
  3. 弹性错误处理

    • JSON解析自动修复:

    fixed = re.sub(r'\\(?!["\\/bfnrtu])', r'\\\\', json_str)
    • 失败案例保存机制:

    with open("error_response.txt", "w") as f:
        f.write(broken_json)

数据流转示例

输入PDF路径/data/resumes/张三_简历.pdf

  1. OCR转换:

    pdf_to_markdown("/data/resumes", "/tmp/markdown")

    生成:/tmp/markdown/张三_简历.md

  2. 结构化提取:

    llm_md2json(md_content, "张三_简历", "/tmp/json")

    输出:/tmp/json/张三_简历.json

  3. 数据库存储:

    {
      "base_info": {"name": "张三", "email": "zhang@example.com"},
      "Work Experience": [{"company": "腾讯", "position": "工程师"}]
    }
  4. 向量化:

    save_or_update_vector_store(
        "/tmp/json/张三_简历.json", 
        "/persistent/vector_store"
    )

该系统的设计巧妙结合了传统文档处理与AI技术,特别适合处理以下场景:

  • 企业大规模简历解析

  • 学术文献结构化

  • 多格式知识库构建

源代码:
 

# processing_logic.py
import os
import subprocess
import time
import sys
import json
import logging
import uuid
from typing import List
from pathlib import Path
from dotenv import load_dotenv
from argparse import Namespace
import torch

load_dotenv() # 确保环境变量被加载

# --- 日志设置 ---
def setup_logging():
    _logger = logging.getLogger() # 获取根 logger
    if not _logger.hasHandlers() or not any(isinstance(h, logging.FileHandler) for h in _logger.handlers):
        # 如果没有配置处理器,或者没有文件处理器,则添加
        log_file_path = os.path.join(os.path.dirname(__file__), 'celery_processing.log')
        logging.basicConfig(
            level=logging.INFO,
            format='%(asctime)s - %(process)d - %(levelname)s - %(module)s - %(funcName)s - %(message)s',
            handlers=[
                logging.FileHandler(log_file_path),
                logging.StreamHandler(sys.stdout)
            ]
        )
        logging.info(f"Logging configured. Log file: {log_file_path}")
    return logging.getLogger(__name__)

logger = setup_logging()
# --- 日志设置结束 ---

# --- 路径设置 ---
_CURRENT_FILE_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.abspath(os.path.join(_CURRENT_FILE_DIR, '../../..'))
GOT_ROOT = os.path.abspath(os.path.join(PROJECT_ROOT, 'backend/app/utils/GOT_OCR/GOT_OCR_master'))

logger.info(f"项目根目录: {PROJECT_ROOT}")
logger.info(f"GOT_OCR目录: {GOT_ROOT}")

# 确保 Celery Worker 运行时能找到这些路径
if PROJECT_ROOT not in sys.path:
    sys.path.insert(0, PROJECT_ROOT)
if GOT_ROOT not in sys.path:
    sys.path.insert(0, GOT_ROOT)
# --- 路径设置结束 ---

from langchain_community.chat_models import ChatOpenAI
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.embeddings import HuggingFaceEmbeddings
import faiss
from langchain_community.docstore.in_memory import InMemoryDocstore
from langchain_community.vectorstores import FAISS
from uuid import uuid4
from langchain_core.documents import Document
from sqlalchemy import create_engine, Column, Integer, String, Text
from sqlalchemy.orm import sessionmaker, declarative_base
from sqlalchemy.pool import QueuePool

# 确保这些导入能正确工作,基于您的项目结构
# 如果 processing_logic.py 在根目录,这些 utils 和 models 应该能直接导入
from app.utils.GOT_OCR.GOT_OCR_master.ocr_processor import process_ocr, get_args

from app.models.resume_model import Base, CompetencyItem, ExternalResume

import torch.multiprocessing as mp
# 尝试设置多进程启动方法,'spawn' 更安全,尤其是在与CUDA等库一起使用时
try:
    if mp.get_start_method(allow_none=True) != 'spawn':
        mp.set_start_method('spawn', force=True)
        print("Multiprocessing start method set to 'spawn'.")
except RuntimeError:
    print("Multiprocessing start method could not be set to 'spawn' (might be already set or not applicable).")




OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
if not OPENAI_API_KEY:
    logger.warning("OPENAI_API_KEY environment variable not set. LLM calls may fail.")
    # 备用key(非常不推荐在生产环境硬编码)
    # OPENAI_API_KEY = "sk-proj-..."

EMBEDDING_MODEL_NAME_OR_PATH = os.getenv("EMBEDDING_MODEL_PATH", os.getenv("EMBEDDING_MODEL_NAME", "lier007/xiaobu-embedding-v2"))
CHAT_MODEL_NAME = os.getenv("CHAT_MODEL_NAME", "gpt-4o")

try:
    logger.info(f"Initializing HuggingFaceEmbeddings with model: {EMBEDDING_MODEL_NAME_OR_PATH}")
    embeddings = HuggingFaceEmbeddings(model_name=EMBEDDING_MODEL_NAME_OR_PATH)
    # 测试 embedding 是否工作
    _ = embeddings.embed_query("test")
    logger.info("HuggingFaceEmbeddings initialized successfully.")
except Exception as e:
    logger.error(f"Error loading embedding model '{EMBEDDING_MODEL_NAME_OR_PATH}': {e}", exc_info=True)
    # 可以在这里抛出异常或者尝试备用方案
    raise  # 或者 sys.exit("Embedding model failed to load.")

text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
    chunk_size=500, chunk_overlap=50
)
# --- 环境和模型初始化结束 ---

# --- 数据库相关函数 ---
def get_db_session(db_path: str):
    """创建并返回一个 SQLAlchemy session。"""
    # 确保 db_path 的目录存在
    os.makedirs(os.path.dirname(db_path), exist_ok=True)
    engine = create_engine(f'sqlite:///{db_path}', poolclass=QueuePool)
    Base.metadata.create_all(engine, checkfirst=True) # checkfirst=True 避免重复创建
    SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
    return SessionLocal()

# LLF_KNOWLEDGE_BASES_FOLDER 现在应该指向实际的 .db 文件
# 从环境变量读取,如果环境变量名在configs.py中定义,则使用它
# 否则,直接使用 "LLF_KNOWLEDGE_BASES_FOLDER" 作为环境变量名
# LLF_KNOWLEDGE_BASES_DB_FILE_PATH = os.getenv(LLF_KNOWLEDGE_BASES_FOLDER_ENV_VAR, os.getenv("LLF_KNOWLEDGE_BASES_FOLDER"))

# 从 .env 中读取 LLF_KNOWLEDGE_BASES_FOLDER
LLF_KNOWLEDGE_BASES_DB_FILE_PATH = os.getenv("LLF_KNOWLEDGE_BASES_FOLDER")

if not LLF_KNOWLEDGE_BASES_DB_FILE_PATH or not os.path.isfile(LLF_KNOWLEDGE_BASES_DB_FILE_PATH):
    logger.warning(
        f"Source CompetencyItem database path ('{LLF_KNOWLEDGE_BASES_DB_FILE_PATH}') "
        f"from env var 'LLF_KNOWLEDGE_BASES_FOLDER' is not set or not a valid file. "
        f"copy_competency_items might fail."
    )
# --- 数据库相关函数结束 ---


# ======== 您提供的所有核心函数将放在这里 ========
# 注意:对 pdf_to_markdown, llm_md2json, add_external_resume_data, save_vector_store 的路径参数进行调整
# 以适应 Celery 任务的临时工作目录机制。

def copy_competency_items(target_session):
    if not LLF_KNOWLEDGE_BASES_DB_FILE_PATH:
        logger.error("LLF_KNOWLEDGE_BASES_FOLDER (source DB path) is not configured.")
        return False
    try:
        source_engine = create_engine(f'sqlite:///{LLF_KNOWLEDGE_BASES_DB_FILE_PATH}')
        SourceSession = sessionmaker(bind=source_engine)
        source_session = SourceSession()
        source_items = source_session.query(CompetencyItem).all()
        if not source_items:
            logger.warning(f"源数据库 {LLF_KNOWLEDGE_BASES_DB_FILE_PATH} 中没有CompetencyItem数据")
            return False
        for item in source_items:
            # 检查目标会话中是否已存在相同 ID 的项
            existing_item = target_session.query(CompetencyItem).filter_by(id=item.id).first()
            if existing_item:
                # 更新现有项 (如果需要)
                existing_item.technology_group = item.technology_group
                existing_item.levelone = item.levelone
                existing_item.leveltwo = item.leveltwo
                # ... 更新其他字段
            else:
                # 添加新项
                new_item = CompetencyItem(
                    id=item.id,
                    technology_group=item.technology_group,
                    levelone=item.levelone,
                    leveltwo=item.leveltwo
                )
                target_session.add(new_item)
        target_session.commit()
        logger.info(f"成功从源数据库复制/更新 {len(source_items)} 条CompetencyItem数据到目标库")
        return True
    except Exception as e:
        logger.error(f"复制CompetencyItem数据时出错: {str(e)}", exc_info=True)
        target_session.rollback()
        return False
    finally:
        if 'source_session' in locals() and source_session:
            source_session.close()


def clear_gpu_memory():
    """清理GPU内存"""
    try:
        if torch.cuda.is_available():
            torch.cuda.empty_cache()
            free_memory = torch.cuda.get_device_properties(0).total_memory - torch.cuda.memory_allocated(0)
            logger.info(f"已清理GPU缓存,当前可用显存: {free_memory / 1024 / 1024 / 1024:.2f}GB")
        else:
            logger.info("未检测到GPU设备")
    except Exception as e:
        logger.warning(f"清理GPU内存时出错: {e}")

def pdf_to_markdown(pdf_input_dir, markdown_output_dir, task_id):
    logger.info(f"[{task_id}] 开始处理PDF文件,输入目录: {pdf_input_dir}")
    
    # 在开始处理前清理GPU内存
    clear_gpu_memory()
    
    if not os.path.exists(pdf_input_dir):
        logger.error(f"[{task_id}] 输入目录不存在: {pdf_input_dir}")
        return []
        
    # 首先列出所有PDF文件
    pdf_files = []
    all_files = os.listdir(pdf_input_dir)
    logger.info(f"[{task_id}] 目录中的所有文件: {all_files}")
    
    for f in all_files:
        if f.lower().endswith('.pdf'):
            pdf_files.append(f)
            logger.info(f"[{task_id}] 发现PDF文件: {f}")
        else:
            logger.warning(f"[{task_id}] 忽略非PDF文件: {f}")
    
    if not pdf_files:
        logger.error(f"[{task_id}] 输入目录中没有PDF文件: {pdf_input_dir}")
        return []
    
    logger.info(f"[{task_id}] 找到 {len(pdf_files)} 个PDF文件待处理")
    
    try:
        # 直接创建参数对象,而不是使用get_args()
        args = Namespace(
            pdf_dir=pdf_input_dir,
            output_dir=markdown_output_dir,
            model_name='stepfun-ai/GOT-OCR2_0',
            gpus_per_node=4,  # 根据实际GPU数量设置
            world_size=1,
            rank=0,
            dist_url='tcp://localhost:23456',
            dist_backend='nccl',
            local_rank=0,
            tasks_per_gpu=2,  # 每个GPU的任务数
            tasks_size=1,
            master_addr='localhost',
            master_port=23456,
            node_rank=0,
            nodes=1
        )
        
        # 使用mp.spawn启动多进程处理所有PDF文件
        mp.spawn(
            process_ocr,
            args=(args,),
            nprocs=args.gpus_per_node * args.tasks_per_gpu,
            join=True
        )
        
        # 获取生成的所有markdown文件
        markdown_files = list(Path(markdown_output_dir).glob("*.md"))
        logger.info(f"[{task_id}] PDF处理完成,共生成 {len(markdown_files)} 个Markdown文件")
        
        # 检查处理结果
        if len(markdown_files) != len(pdf_files):
            logger.warning(f"[{task_id}] 警告:生成的Markdown文件数量({len(markdown_files)})与输入的PDF文件数量({len(pdf_files)})不一致")
            
        return markdown_files
        
    except Exception as e:
        logger.error(f"[{task_id}] 处理PDF文件时发生错误: {str(e)}", exc_info=True)
        clear_gpu_memory()
        return []
        
    finally:
        # 处理完成后清理GPU内存
        clear_gpu_memory()


def convert_mathpix_to_markdown(markdown_content: str, task_id: str = "unknown_task"):
    logger.info(f"[{task_id}] Task: convert_mathpix_to_markdown - Starting conversion.")
    llm = ChatOpenAI(model=CHAT_MODEL_NAME, temperature=0.1, api_key=OPENAI_API_KEY)
    # ... (您的 MATHPIX_RULES 和 prompt 保持不变) ...
    MATHPIX_RULES = """Mathpix Markdown ...""" # (省略以保持简洁)
    prompt = f"""
    我现在需要你帮我把mathpix Markdown格式文件处理成正常的markdown文件。你只需要返回文件内容,不需要返回任何其他内容。注意:1.务必保证信息完整,内容与原文一致。2. 取消page页,保证内容连续 3.结合语义检查有些标题是否需要删除,比如错把一句话作为标题的情况。4.结合语义检查是否有遗漏的标题,常见标题比如:基本信息/教育背景/工作经历/科研经历/成果/奖励/个人技能。

    mathpix Markdown的区别是:{MATHPIX_RULES}。

    需要转化的文件内容是:
    {markdown_content}
    """
    try:
        converted_content = llm.invoke(prompt).content
        logger.info(f"[{task_id}] Mathpix Markdown conversion successful.")
        return converted_content
    except Exception as e:
        logger.error(f"[{task_id}] Error during Mathpix Markdown conversion with LLM: {e}", exc_info=True)
        raise # 重新抛出异常,让 Celery 任务标记为失败


def fix_invalid_escapes(json_str: str):
    import re
    # Fixed regex: r'\\(?!["\\/bfnrtu])'
    # It means a backslash NOT followed by a quote, backslash, slash, or b,f,n,r,t,u
    fixed = re.sub(r'\\(?!["\\/bfnrtu])', r'\\\\', json_str)
    return fixed

def llm_md2json(markdown_content: str, source_filename_stem: str, json_output_dir: str, task_id: str = "unknown_task"):
    """
    将markdown内容转换为JSON格式并保存。
    Args:
        markdown_content: 清洗后的 markdown 内容。
        source_filename_stem: 源文件名 (不含扩展名),用于命名输出的 JSON。
        json_output_dir: JSON 文件的输出目录 (通常是Celery任务临时目录下的 'json' 子目录)。
        task_id: Celery 任务ID。
    Returns:
        tuple: (保存的JSON文件路径, 解析后的JSON内容 dict) 或 (None, None) on error
    """
    logger.info(f"[{task_id}] Task: llm_md2json - Processing: {source_filename_stem}.md")
    # 将文件名添加到内容中,这部分逻辑和原先一致
    content_with_filename = f"文件名:{source_filename_stem}\n\n{markdown_content}"

    llm = ChatOpenAI(model=CHAT_MODEL_NAME, temperature=0.1, api_key=OPENAI_API_KEY)
    # ... (您的 PROMPT_TEMPLATE 保持不变) ...
    PROMPT_TEMPLATE = """
    请按以下步骤执行:

    1. 理解提供的内容,并进行分类,一共有三种分类,分别是:
        - 外部人员简历
            判断方法:如果内容中包含姓名、年龄、邮箱、手机号码、学校、专业、学历、毕业时间、公司、职位、开始时间、结束时间、描述、项目名称、项目角色、项目时间、项目描述、技能、论文、专利、奖项,则认为是外部人员简历。
        - 伙伴工作经历
            判断方法:如果内容中包含伙伴工作经历和新奥主要工作经历,则认为是伙伴工作经历。
        - 其他内容
            判断方法:如果内容中不包含外部人员简历和伙伴工作经历,则认为是其他内容。
        

    2. 根据第一步的分类结果,并按照每种分类的处理步骤执行,最后仅返回json格式内容,不要返回其他内容。
    - 如果内容是外部人员简历,则按照以下步骤执行:
        2.1. 理解用户提供的简历内容,对简历内容进行分类,并提取出每个分类中的关键信息。
        2.2. 从简历内容中识别出个人基本信息base_info,包含姓名、年龄、邮箱、手机号码。若个人基本信息中不包含姓名,可以从文件名中提取。
        2.3. 从简历内容中识别出教育背景信息Educational Background,包含学校、专业、学历、毕业时间。要按照时间区分不同阶段的学历(本科、硕士或博士)。
        2.4. 从简历内容中识别出工作经历信息Work Experience,包含公司、职位、开始时间、结束时间、描述(工作内容)。
        2.5. 将项目名称、项目角色、项目时间、项目描述生成项目经历信息Project Experience。
        2.6. 将技能生成Skills
        2.7. 将论文、专利等生成研究成果Research achievements
        2.8. 将奖项生成Award
        所有信息整合以下面json模板格式提供,不要返回其他内容。如果文中不包含某些键的相关信息,请返回空字符串。
        外部人员简历json模板如下:
        {{   
            "base_info":{{
                "name": "张三",
                "出生年月": "1998-01",
                "年龄": 25,
                "email": "zhangsan@example.com",
                "phone": "1234567890"
            }},
            "Educational Background":[{{
                "school": "北京大学",
                "major": "计算机科学与技术",
                "degree": "本科"
                "start_time": "2018-09-01",
                "end_time": "2022-06-30"
            }},
            {{
                "school": "清华大学",
                "major": "计算机科学与技术",
                "degree": "硕士"
                "start_time": "2022-09-01",
                "end_time": "2025-06-30"
            }}],
            "Work Experience":[{{
                "company": "腾讯",
                "position": "软件工程师",
                "start_time": "2022-09-01",
                "end_time": "2025-06-30",
                "description": "负责腾讯云的开发和维护"
            }}],
            "Project Experience":[{{
                "project_name": "项目1",
                "project_role": "项目经理",
                "project_time": "2020-2021",
                "project_description": "项目描述"
            }}],
            "Skills":["Python", "Java", "SQL"],
            "Research achievements":["论文1", "论文2", "论文3"],
            "Award":["奖项1", "奖项2", "奖项3"]
        }}

    - 如果内容是伙伴工作经历,则按照以下步骤执行:
        2.1. 理解用户提供的伙伴工作经历内容,对工作经历内容进行分类,并提取出每个分类中的关键信息。
        2.2. 将个人基本信息如姓名、性别、出生年月、专业技术职称、最高学历、第一学历、政治面貌、开始工作时间生成个人基本信息base_info。
        
        }}

    - 如果内容是其他内容,则按照以下步骤执行:
        2.1. 理解用户提供的内容,并按照内容进行分类,并提取出每个分类中的关键信息。
        2.2. 将内容按照分类进行整合,并按照json格式提供。

    注意:
    1、仅对原内容进行分类提取,保证内容的完整性和一致性,不做任何补充、删减和修改。
    2、从全文寻找分类内容,


    简历内容:```{context}```
    """ # (省略以保持简洁)
    prompt_template_obj = PromptTemplate(input_variables=["context"], template=PROMPT_TEMPLATE)
    chain = prompt_template_obj | llm

    try:
        response_text = chain.invoke({"context": content_with_filename}).content
    except Exception as e:
        logger.error(f"[{task_id}] LLM invocation failed for {source_filename_stem}: {e}", exc_info=True)
        return None, None # 指示错误
    response_text = response_text.replace("```json", "").replace("```", "").strip()
    
    parsed_json_data = None
    try:
        parsed_json_data = json.loads(response_text)
    except json.JSONDecodeError as e:
        logger.warning(f"[{task_id}] JSON解析错误 for {source_filename_stem}: {e}. 尝试修复...")
        try:
            fixed_response = fix_invalid_escapes(response_text)
            parsed_json_data = json.loads(fixed_response)
            logger.info(f"[{task_id}] 成功修复并解析JSON for {source_filename_stem}")
        except Exception as fix_error:
            logger.error(f"[{task_id}] 修复JSON失败 for {source_filename_stem}: {fix_error}", exc_info=True)
            # 保存问题响应以供调试
            debug_path = os.path.join(json_output_dir, f"{source_filename_stem}_error_response.txt")
            os.makedirs(os.path.dirname(debug_path), exist_ok=True)
            with open(debug_path, "w", encoding="utf-8") as df:
                df.write(response_text)
            logger.info(f"[{task_id}] Problematic LLM response saved to {debug_path}")
            return None, None # 指示错误

    os.makedirs(json_output_dir, exist_ok=True)
    output_json_path = os.path.join(json_output_dir, source_filename_stem + '.json')

    try:
        with open(output_json_path, "w", encoding="utf-8") as f:
            json.dump(parsed_json_data, f, ensure_ascii=False, indent=4)
        logger.info(f"[{task_id}] 分析结果已保存到 {output_json_path}")
        return output_json_path, parsed_json_data
    except Exception as e:
        logger.error(f"[{task_id}] 保存JSON文件 {output_json_path} 失败: {e}", exc_info=True)
        return None, None


def process_json_file_for_vectorization(json_file_path: str, task_id: str = "unknown_task") -> List[Document]:
    """处理单个JSON文件并返回用于向量化的Document列表"""
    filename = os.path.basename(json_file_path)
    logger.info(f"[{task_id}] Task: process_json_file_for_vectorization - Processing JSON: {filename}")
    try:
        with open(json_file_path, 'r', encoding='utf-8') as f:
            json_data = json.load(f)
        
        text_content = json.dumps(json_data, ensure_ascii=False, indent=2)
        doc_type = "external_resume" # 默认类型,可以根据json_data内容动态判断
        # 示例:简单判断逻辑
        if "enn_experience" in json_data or "social_experience" in json_data:
             doc_type = "partner_work_experience"
        elif "base_info" not in json_data : # 如果连 base_info 都没有,可能不是简历
             doc_type = "other_content"


        doc = Document(
            page_content=text_content,
            metadata={
                'file_name': filename, # JSON 文件名
                'file_type': 'json',
                'source': json_file_path, # JSON 文件的绝对路径
                'type': doc_type # 从您的提示中获取的分类
            }
        )
        splits = text_splitter.split_documents([doc])
        logger.info(f"[{task_id}] 文件 {filename} 已切分为 {len(splits)} 个片段用于向量化")
        return splits
    except Exception as e:
        logger.error(f"[{task_id}] 处理JSON文件 {filename} 进行向量化时出错: {e}", exc_info=True)
        return []


def save_or_update_vector_store(json_file_path: str, persistent_vector_store_path: str, task_id: str = "unknown_task"):
    """
    根据单个JSON文件内容,创建或更新向量库。
    Args:
        json_file_path: 要处理的JSON文件路径。
        persistent_vector_store_path: 持久化FAISS向量库的目录路径。
        task_id: Celery任务ID。
    Returns:
        bool: 操作是否成功。
    """
    logger.info(f"[{task_id}] Task: save_or_update_vector_store - For JSON: {os.path.basename(json_file_path)}, Target VS: {persistent_vector_store_path}")
    global embeddings # 使用模块级初始化的 embeddings

    os.makedirs(persistent_vector_store_path, exist_ok=True)
    
    doc_splits = process_json_file_for_vectorization(json_file_path, task_id)
    if not doc_splits:
        logger.error(f"[{task_id}] 没有为 {os.path.basename(json_file_path)} 生成任何文档切分,无法更新向量库。")
        return False
        
    index_file = os.path.join(persistent_vector_store_path, "index.faiss")
    pkl_file = os.path.join(persistent_vector_store_path, "index.pkl")

    # FAISS 实例不应该作为全局变量被修改,而是在函数内加载/创建/保存
    current_vector_store: FAISS = None

    if os.path.exists(index_file) and os.path.exists(pkl_file):
        logger.info(f"[{task_id}] 发现现有向量库于 {persistent_vector_store_path},正在加载...")
        try:
            current_vector_store = FAISS.load_local(
                persistent_vector_store_path, embeddings, allow_dangerous_deserialization=True
            )
            logger.info(f"[{task_id}] 现有向量库加载完成。正在添加新文档...")
            # 为新文档生成UUID
            new_doc_ids = [str(uuid4()) for _ in doc_splits]
            current_vector_store.add_documents(documents=doc_splits, ids=new_doc_ids)
        except Exception as load_err:
            logger.error(f"[{task_id}] 加载现有向量库失败: {load_err}. 将创建新的向量库。", exc_info=True)
            # 如果加载失败,则基于当前文档创建新库
            doc_ids = [str(uuid4()) for _ in doc_splits]
            current_vector_store = FAISS.from_documents(doc_splits, embeddings, ids=doc_ids)
    else:
        logger.info(f"[{task_id}] 未找到现有向量库于 {persistent_vector_store_path} 或不完整。创建新的向量库...")
        doc_ids = [str(uuid4()) for _ in doc_splits]
        current_vector_store = FAISS.from_documents(doc_splits, embeddings, ids=doc_ids)
    
    try:
        current_vector_store.save_local(persistent_vector_store_path)
        logger.info(f"[{task_id}] 向量库已成功保存/更新到: {persistent_vector_store_path} (来源: {os.path.basename(json_file_path)})")
        return True
    except Exception as e:
        logger.error(f"[{task_id}] 保存向量库到 {persistent_vector_store_path} 时出错: {e}", exc_info=True)
        return False


def add_or_update_resume_in_db(json_data: dict, source_json_filename: str, db_path: str, task_id: str = "unknown_task"):
    """
    根据JSON数据添加或更新数据库中的外部简历信息。
    Args:
        json_data: 解析后的简历JSON数据 (dict)。
        source_json_filename: 源JSON文件名 (不含路径,例如 'resume_abc.json')。
        db_path: SQLite数据库文件的完整路径。
        task_id: Celery任务ID。
    Returns:
        bool: 操作是否成功。
    """
    logger.info(f"[{task_id}] Task: add_or_update_resume_in_db - For JSON: {source_json_filename}, Target DB: {db_path}")
    session = get_db_session(db_path)
    try:
        base_info = json_data.get('base_info', {})
        original_name = base_info.get('name', '未知姓名')
        
        # --- 数据提取逻辑 (与您原代码类似,此处简化表示) ---
        educational_background_list = json_data.get('Educational Background', [])
        educational_background = "\n".join([f"{e.get('start_time','')}至{e.get('end_time','')}, {e.get('school','')}, {e.get('major','')}, {e.get('degree','')}" for e in educational_background_list])

        work_experience_list = json_data.get('Work Experience', [])
        work_experience = "\n".join([f"{e.get('start_time','')}至{e.get('end_time','')}, {e.get('company','')}, {e.get('position','')}, {e.get('description','')}" for e in work_experience_list])

        project_experience_list = json_data.get('Project Experience', [])
        project_experience = "\n".join([f"{p.get('project_name','')}, {p.get('project_role','')}, {p.get('project_time','')}, {p.get('project_description','')}" for p in project_experience_list])
        
        skills = "\n".join(json_data.get('Skills', []))
        research_achievements = "\n".join(json_data.get('Research achievements', []))
        awards = "\n".join(json_data.get('Award', []))
        # --- 数据提取逻辑结束 ---

        resume_data_dict = {
            'file_name': source_json_filename, # 使用纯文件名
            'name': original_name,
            'birth_date': base_info.get('出生年月', ''),
            'email': base_info.get('email', ''),
            'phone': base_info.get('phone', ''),
            'educational_background': educational_background.strip(),
            'work_experience': work_experience.strip(),
            'project_experience': project_experience.strip(),
            'skill': skills.strip(),
            'research_achievement': research_achievements.strip(),
            'award': awards.strip()
        }
        
        # 您的同名/同前缀更新逻辑
        # 为了简化,这里假设 file_name 是唯一的,如果存在则更新,否则创建
        existing_resume = session.query(ExternalResume).filter_by(file_name=source_json_filename).first()
        action = ""
        if existing_resume:
            logger.info(f"[{task_id}] 发现现有记录,将更新: {original_name} (文件名: {source_json_filename})")
            for key, value in resume_data_dict.items():
                setattr(existing_resume, key, value)
            action = "更新"
        else:
            logger.info(f"[{task_id}] 未发现记录,将创建新记录: {original_name} (文件名: {source_json_filename})")
            resume_data_dict['uuid'] = str(uuid.uuid4()) # 生成新的UUID
            new_resume = ExternalResume(**resume_data_dict)
            session.add(new_resume)
            action = "添加"
        
        session.commit()
        logger.info(f"[{task_id}] 成功{action}外部简历数据: {original_name} (文件: {source_json_filename})")

        # 检查并复制 CompetencyItem 数据
        competency_count = session.query(CompetencyItem).count()
        if competency_count == 0:
            logger.info(f"[{task_id}] competency_item表为空,开始从LLF数据库复制数据")
            copy_success = copy_competency_items(session) # 传递当前会话
            if copy_success:
                logger.info(f"[{task_id}] 成功完成CompetencyItem数据复制")
            else:
                logger.error(f"[{task_id}] CompetencyItem数据复制失败")
        return True
    except Exception as e:
        session.rollback()
        logger.error(f"[{task_id}] 处理数据库时发生错误 for {source_json_filename}: {e}", exc_info=True)
        return False
    finally:
        session.close()


def process_single_markdown_file(
    markdown_file_path: Path,
    task_temp_dir: str, # Celery 任务的专属临时目录
    persistent_vector_store_path: str, # 最终向量库的持久化路径
    persistent_db_path: str, # 最终结构化数据的数据库路径
    persistent_json_dir: str, # 最终JSON文件的持久化路径
    task_id: str
):
    """
    处理单个markdown文件的完整流程:MD -> JSON -> DB -> VectorStore
    Args:
        markdown_file_path (Path): 要处理的 markdown 文件的 Path 对象。
        task_temp_dir (str): 此 Celery 任务运行的根临时目录。JSON会存放在此目录的 'json' 子文件夹。
        persistent_vector_store_path (str): 最终的、持久化的向量数据库路径。
        persistent_db_path (str): 最终的、持久化的结构化数据SQLite数据库路径。
        persistent_json_dir (str): 最终的、持久化的JSON文件路径。
        task_id (str): Celery 任务ID,用于日志跟踪。
    Returns:
        bool: 此文件处理是否成功。
    """
    markdown_filename_stem = markdown_file_path.stem
    logger.info(f"[{task_id}] 开始处理单个Markdown文件: {markdown_file_path.name}")

    try:
        with open(markdown_file_path, 'r', encoding='utf-8') as f:
            raw_markdown_content = f.read()
        
        cleaned_markdown_content = convert_mathpix_to_markdown(raw_markdown_content, task_id)
        logger.info(f"[{task_id}] 完成Markdown内容清洗 for {markdown_filename_stem}")
        
        # JSON 文件将输出到任务临时目录下的 'json' 子目录
        json_output_temp_dir = persistent_json_dir
        
        json_file_path, json_content_dict = llm_md2json(
            cleaned_markdown_content, markdown_filename_stem, json_output_temp_dir, task_id
        )
        
        if not json_file_path or not json_content_dict:
            logger.error(f"[{task_id}] Markdown转JSON失败 for {markdown_filename_stem}. 跳过后续处理。")
            return False
        logger.info(f"[{task_id}] 完成JSON转换: {json_file_path}")
        
        # 添加到结构化数据库 (使用持久化DB路径)
        db_add_success = add_or_update_resume_in_db(
            json_content_dict, os.path.basename(json_file_path), persistent_db_path, task_id
        )
        if not db_add_success:
            logger.warning(f"[{task_id}] 添加/更新数据库记录失败 for {markdown_filename_stem}. 但仍会尝试添加到向量库。")
            # 根据需求,这里可以选择 return False

        # 添加到向量库 (使用持久化VS路径)
        vs_add_success = save_or_update_vector_store(json_file_path, persistent_vector_store_path, task_id)
        if not vs_add_success:
            logger.error(f"[{task_id}] 添加到向量库失败 for {markdown_filename_stem}.")
            return False # 如果向量库添加失败,则认为此文件处理失败
            
        logger.info(f"[{task_id}] 文件 {markdown_filename_stem} 处理成功。")
        return True
        
    except Exception as e:
        logger.error(f"[{task_id}] 处理文件 {markdown_filename_stem} 时发生未捕获的严重错误: {e}", exc_info=True)
        return False


# 这个函数是 Celery 任务将要调用的主入口
def full_processing_pipeline_for_celery(
    pdf_input_dir: str,                  # API 传入的包含PDF的目录
    persistent_vector_store_path: str,   # 此知识库的持久化向量库路径
    persistent_db_path: str,             # 此知识库的持久化SQLite数据库路径
    persistent_json_dir: str,            # 此知识库的持久化JSON文件路径
    task_temp_dir: str,                  # Celery为此任务分配的根临时工作目录
    task_id: str                         # Celery 任务的 ID
):
    """
    完整的处理流水线,由Celery任务调用。
    1. PDF -> Markdown (Markdown输出到 task_temp_dir/markdown_files)
    2. Foreach Markdown: MD -> JSON -> DB -> VectorStore
       (JSON输出到 persistent_json_dir)
       (DB 和 VectorStore 直接更新到持久化路径)
    """
    logger.info(f"[{task_id}] Celery Task Pipeline Started. PDFs from: {pdf_input_dir}, VS Path: {persistent_vector_store_path}, DB Path: {persistent_db_path}, Task Temp Dir: {task_temp_dir}")

    # 1. PDF -> Markdown
    # Markdown 文件输出到当前 Celery 任务的临时目录下的 'markdown_files' 子目录
    markdown_output_temp_dir = os.path.join(task_temp_dir, 'markdown_files')
    os.makedirs(markdown_output_temp_dir, exist_ok=True)

    markdown_file_paths = pdf_to_markdown(pdf_input_dir, markdown_output_temp_dir, task_id)
    if not markdown_file_paths:
        logger.error(f"[{task_id}] PDF转Markdown失败或未生成任何文件。终止处理。")
        return {"status": "FAILURE", "message": "PDF to Markdown conversion failed.", "processed_files": 0, "failed_files": 0}

    logger.info(f"[{task_id}] {len(markdown_file_paths)} Markdown 文件已生成于: {markdown_output_temp_dir}")

    # 2. 并行处理每个 Markdown 文件
    # num_processes = int(os.getenv("SUB_PROCESSES_PER_TASK", "2")) # 从环境变量读取或默认
    # 您的原始代码是 num_processes = 4, 这里可以保持或配置
    num_processes_for_md = min(max(1, (os.cpu_count() or 4) // 2), 4) # 例如,最多4个,至少1个
    
    logger.info(f"[{task_id}] 使用 {num_processes_for_md} 个子进程并行处理 {len(markdown_file_paths)} 个markdown文件。")
    
    # 准备参数列表 (markdown_file_path, task_temp_dir, persistent_vector_store_path, persistent_db_path, task_id)
    # 注意:task_temp_dir 对于每个子进程内的 process_single_markdown_file 是一样的,
    # 子函数 llm_md2json 会在 task_temp_dir/json_files 下创建各自的json文件。
    params_for_starmap = [
        (md_path, task_temp_dir, persistent_vector_store_path, persistent_db_path,persistent_json_dir, task_id)
        for md_path in markdown_file_paths
    ]
    
    results = []
    if not params_for_starmap:
        logger.info(f"[{task_id}] 没有 Markdown 文件需要处理。")
    elif num_processes_for_md > 0 :
        # mp.Pool 应该在 if __name__ == '__main__' 中或者在可以安全使用 fork/spawn 的地方。
        # Celery worker 本身已经是多进程/多线程模型,在其内部再创建 Pool 需要小心。
        # prefork pool (Celery默认) + mp.Pool (spawn) 通常是可行的。
        try:
            # ctx = mp.get_context('spawn') # 确保使用 spawn 模式以增加稳定性
            # with ctx.Pool(processes=num_processes_for_md) as pool:
            # 如果 mp.Pool 在 Celery 任务中引起问题,可以考虑改为顺序执行或使用 Celery Group/Chain
            with mp.Pool(processes=num_processes_for_md) as pool:
                results = pool.starmap(process_single_markdown_file, params_for_starmap)
        except Exception as e:
            logger.error(f"[{task_id}] Markdown文件并行处理时发生错误: {e}", exc_info=True)
            # 尝试顺序执行作为后备
            logger.info(f"[{task_id}] 尝试顺序执行Markdown文件处理...")
            results = []
            for p in params_for_starmap:
                results.append(process_single_markdown_file(*p))
    else: # 如果 num_processes_for_md = 0 (例如配置问题),则顺序执行
        logger.info(f"[{task_id}] 顺序执行Markdown文件处理 (num_processes_for_md <= 0)")
        for p in params_for_starmap:
            results.append(process_single_markdown_file(*p))

    successful_files_count = sum(1 for r in results if r is True)
    failed_files_count = len(results) - successful_files_count
    
    logger.info(f"[{task_id}] Markdown文件处理完成。成功: {successful_files_count}, 失败: {failed_files_count}")
    
    final_status = "SUCCESS"
    if failed_files_count > 0 and successful_files_count > 0:
        final_status = "PARTIAL_SUCCESS"
    elif failed_files_count > 0 and successful_files_count == 0 and len(markdown_file_paths) > 0:
        final_status = "FAILURE"
    elif not markdown_file_paths: # 没有文件从PDF转换过来
        final_status = "NO_FILES_PROCESSED"
        
    return {
        "status": final_status,
        "message": f"Total MD files: {len(markdown_file_paths)}. Processed successfully: {successful_files_count}, Failed: {failed_files_count}.",
        "successful_count": successful_files_count,
        "failed_count": failed_files_count,
        "markdown_files_generated": len(markdown_file_paths)
    }

# ======== 核心函数定义结束 ========

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

海绵波波107

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值