deepdata: core product code (backend, frontend, runtime, deepflow module)
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
Shared Module Init
|
||||
共享模块初始化
|
||||
"""
|
||||
from .file_service import FileService
|
||||
from .file_models import (
|
||||
ChunkUploadPreRequestDto,
|
||||
ChunkUploadRequestDto,
|
||||
FileUploadResult,
|
||||
)
|
||||
from .chunks_saver import ChunksSaver
|
||||
from .chunk_upload_repository import ChunkUploadRepository
|
||||
|
||||
__all__ = [
|
||||
"FileService",
|
||||
"ChunkUploadPreRequestDto",
|
||||
"ChunkUploadRequestDto",
|
||||
"FileUploadResult",
|
||||
"ChunksSaver",
|
||||
"ChunkUploadRepository",
|
||||
]
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Chunk Upload Repository
|
||||
分片上传数据访问层
|
||||
"""
|
||||
from typing import Optional, List
|
||||
|
||||
from sqlalchemy import select, update, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models.chunk_upload import ChunkUploadPreRequest
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ChunkUploadRepository:
|
||||
"""分片上传数据访问层"""
|
||||
|
||||
async def find_by_id(
|
||||
self,
|
||||
req_id: str,
|
||||
db: AsyncSession
|
||||
) -> Optional[ChunkUploadPreRequest]:
|
||||
"""根据ID查询"""
|
||||
result = await db.execute(
|
||||
select(ChunkUploadPreRequest).where(ChunkUploadPreRequest.id == req_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def find_by_service_id(
|
||||
self,
|
||||
service_id: str,
|
||||
db: AsyncSession
|
||||
) -> List[ChunkUploadPreRequest]:
|
||||
"""根据服务ID查询"""
|
||||
result = await db.execute(
|
||||
select(ChunkUploadPreRequest).where(
|
||||
ChunkUploadPreRequest.service_id == service_id
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
async def find_all(self, db: AsyncSession) -> List[ChunkUploadPreRequest]:
|
||||
"""查询所有"""
|
||||
result = await db.execute(select(ChunkUploadPreRequest))
|
||||
return result.scalars().all()
|
||||
|
||||
async def insert(
|
||||
self,
|
||||
request: ChunkUploadPreRequest,
|
||||
db: AsyncSession
|
||||
) -> None:
|
||||
"""插入"""
|
||||
db.add(request)
|
||||
|
||||
async def update(
|
||||
self,
|
||||
request: ChunkUploadPreRequest,
|
||||
db: AsyncSession
|
||||
) -> int:
|
||||
"""更新"""
|
||||
from datetime import datetime, timezone
|
||||
result = await db.execute(
|
||||
update(ChunkUploadPreRequest)
|
||||
.where(ChunkUploadPreRequest.id == request.id)
|
||||
.values(
|
||||
uploaded_file_num=request.uploaded_file_num,
|
||||
timeout=request.timeout,
|
||||
)
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
async def delete_by_id(
|
||||
self,
|
||||
req_id: str,
|
||||
db: AsyncSession
|
||||
) -> int:
|
||||
"""根据ID删除"""
|
||||
result = await db.execute(
|
||||
delete(ChunkUploadPreRequest).where(ChunkUploadPreRequest.id == req_id)
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
async def delete_by_service_id(
|
||||
self,
|
||||
service_id: str,
|
||||
db: AsyncSession
|
||||
) -> int:
|
||||
"""根据服务ID删除"""
|
||||
result = await db.execute(
|
||||
delete(ChunkUploadPreRequest).where(
|
||||
ChunkUploadPreRequest.service_id == service_id
|
||||
)
|
||||
)
|
||||
return result.rowcount
|
||||
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Chunks Saver
|
||||
分片保存器,用于处理文件分片上传
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import UploadFile
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.module.shared.file_models import ChunkUploadRequestDto
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ChunksSaver:
|
||||
"""分片保存器"""
|
||||
|
||||
TEMP_DIR_NAME_FORMAT = "req_%s_chunks"
|
||||
|
||||
@staticmethod
|
||||
def save(
|
||||
file_upload_request: ChunkUploadRequestDto,
|
||||
pre_upload_req_id: str,
|
||||
upload_path: str,
|
||||
file_content: bytes
|
||||
) -> Optional[Path]:
|
||||
"""
|
||||
保存分片
|
||||
|
||||
Args:
|
||||
file_upload_request: 上传分片的请求
|
||||
pre_upload_req_id: 预上传请求ID
|
||||
upload_path: 上传基础路径
|
||||
file_content: 文件内容(字节)
|
||||
|
||||
Returns:
|
||||
保存后的文件路径,如果不是最后一个分片则返回None
|
||||
"""
|
||||
start_time = datetime.now(timezone.utc)
|
||||
|
||||
temp_dir = Path(upload_path) / (
|
||||
ChunksSaver.TEMP_DIR_NAME_FORMAT % pre_upload_req_id
|
||||
)
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
temp_file = temp_dir / str(file_upload_request.file_no)
|
||||
|
||||
ChunksSaver._append_to_target_file(temp_file, file_content)
|
||||
|
||||
if file_upload_request.total_chunk_num != file_upload_request.chunk_no:
|
||||
elapsed = (datetime.now(timezone.utc) - start_time).total_seconds()
|
||||
logger.debug(f"save chunk {file_upload_request.chunk_no} cost {elapsed}s")
|
||||
return None
|
||||
|
||||
final_file = Path(upload_path) / file_upload_request.file_name
|
||||
|
||||
# 防止路径穿越:规范化后校验仍在 upload_path 下
|
||||
resolved_final = final_file.resolve()
|
||||
resolved_base = Path(upload_path).resolve()
|
||||
if not str(resolved_final).startswith(str(resolved_base) + os.sep):
|
||||
logger.error(
|
||||
f"Path traversal attempt in chunk save: file_name={file_upload_request.file_name}, "
|
||||
f"resolved_path={resolved_final}"
|
||||
)
|
||||
raise ValueError("Path traversal detected in file name")
|
||||
|
||||
try:
|
||||
temp_file.rename(final_file)
|
||||
except OSError as e:
|
||||
logger.error(
|
||||
f"failed to mv file: {temp_file.name}, req id: {pre_upload_req_id}, error: {e}"
|
||||
)
|
||||
raise ValueError("failed to move file to target dir") from e
|
||||
|
||||
elapsed = (datetime.now(timezone.utc) - start_time).total_seconds()
|
||||
logger.debug(f"save chunk {file_upload_request.chunk_no} cost {elapsed}s")
|
||||
|
||||
return final_file
|
||||
|
||||
@staticmethod
|
||||
def save_file(
|
||||
file_upload_request: ChunkUploadRequestDto,
|
||||
upload_path: str,
|
||||
file_content: bytes
|
||||
) -> Path:
|
||||
"""
|
||||
保存文件(不分片)
|
||||
|
||||
Args:
|
||||
file_upload_request: 上传请求
|
||||
upload_path: 上传路径
|
||||
file_content: 文件内容(字节)
|
||||
|
||||
Returns:
|
||||
保存后的文件路径
|
||||
"""
|
||||
target_file = Path(upload_path) / file_upload_request.file_name
|
||||
|
||||
# 防止路径穿越:规范化后校验仍在 upload_path 下
|
||||
resolved_target = target_file.resolve()
|
||||
resolved_base = Path(upload_path).resolve()
|
||||
if not str(resolved_target).startswith(str(resolved_base) + os.sep):
|
||||
logger.error(
|
||||
f"Path traversal attempt in save_file: file_name={file_upload_request.file_name}, "
|
||||
f"resolved_path={resolved_target}"
|
||||
)
|
||||
raise ValueError("Path traversal detected in file name")
|
||||
|
||||
logger.info(f"file path {target_file}, file size {len(file_content)}")
|
||||
|
||||
try:
|
||||
target_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_file.write_bytes(file_content)
|
||||
except OSError as e:
|
||||
logger.error(f"failed to save file: {target_file}, error: {e}")
|
||||
raise ValueError("failed to save file") from e
|
||||
|
||||
return target_file
|
||||
|
||||
@staticmethod
|
||||
def delete_folder(folder_path: str) -> None:
|
||||
"""
|
||||
删除指定路径下的所有文件
|
||||
|
||||
Args:
|
||||
folder_path: 文件夹路径
|
||||
"""
|
||||
folder = Path(folder_path)
|
||||
|
||||
if not folder.exists():
|
||||
logger.info(f"folder {folder_path} does not exist")
|
||||
return
|
||||
|
||||
try:
|
||||
for item in folder.glob("*"):
|
||||
if item.is_file():
|
||||
item.unlink()
|
||||
elif item.is_dir():
|
||||
for sub_item in item.glob("*"):
|
||||
if sub_item.is_file():
|
||||
sub_item.unlink()
|
||||
elif sub_item.is_dir():
|
||||
ChunksSaver.delete_folder(str(sub_item))
|
||||
item.rmdir()
|
||||
except OSError as e:
|
||||
logger.error(f"failed to delete folder: {folder_path}, error: {e}")
|
||||
raise ValueError("failed to delete folder") from e
|
||||
|
||||
@staticmethod
|
||||
def _append_to_target_file(target_file: Path, content: bytes) -> None:
|
||||
"""
|
||||
追加内容到目标文件末尾
|
||||
|
||||
Args:
|
||||
target_file: 目标文件
|
||||
content: 要追加的内容
|
||||
"""
|
||||
try:
|
||||
with open(target_file, "ab") as f:
|
||||
f.write(content)
|
||||
except OSError as e:
|
||||
logger.error(f"failed to append to file: {target_file}, error: {e}")
|
||||
raise ValueError("failed to append content to file") from e
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
公共模块
|
||||
"""
|
||||
@@ -0,0 +1,115 @@
|
||||
from pathlib import Path
|
||||
from typing import List, Union, Optional
|
||||
|
||||
from langchain_community.document_loaders import (
|
||||
TextLoader,
|
||||
JSONLoader,
|
||||
CSVLoader,
|
||||
UnstructuredMarkdownLoader,
|
||||
UnstructuredFileLoader,
|
||||
UnstructuredExcelLoader,
|
||||
PyPDFLoader,
|
||||
Docx2txtLoader, BSHTMLLoader
|
||||
)
|
||||
from langchain_core.documents import Document
|
||||
|
||||
from app.core.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
class UniversalDocLoader:
|
||||
"""
|
||||
通用泛文本文档加载类
|
||||
支持格式:TXT/JSON/CSV/Markdown/Word(.docx)/PPT(.pptx)/PDF/Excel(.xlsx,.xls)/各种文本文件
|
||||
"""
|
||||
# 格式-加载器映射(轻量优先)
|
||||
SUPPORTED_FORMATS = {
|
||||
# 纯文本类
|
||||
".txt": TextLoader,
|
||||
".json": JSONLoader,
|
||||
".csv": CSVLoader,
|
||||
".md": UnstructuredMarkdownLoader,
|
||||
".markdown": UnstructuredMarkdownLoader,
|
||||
".log": TextLoader,
|
||||
".xml": TextLoader,
|
||||
".html": BSHTMLLoader,
|
||||
".htm": BSHTMLLoader,
|
||||
".yaml": TextLoader,
|
||||
".yml": TextLoader,
|
||||
# 办公文档类
|
||||
".docx": Docx2txtLoader,
|
||||
".doc": Docx2txtLoader,
|
||||
".pptx": UnstructuredFileLoader,
|
||||
".ppt": UnstructuredFileLoader,
|
||||
".xlsx": UnstructuredExcelLoader,
|
||||
".xls": UnstructuredExcelLoader,
|
||||
# PDF 类
|
||||
".pdf": PyPDFLoader
|
||||
}
|
||||
|
||||
def __init__(self, file_path: Union[str, Path]):
|
||||
self.file_path = Path(file_path).resolve()
|
||||
self.file_suffix = self.file_path.suffix.lower()
|
||||
log.info(f"初始化文档加载器: {self.file_path} (格式: {self.file_suffix})")
|
||||
self._validate_file()
|
||||
|
||||
def _validate_file(self) -> None:
|
||||
"""验证文件存在性和格式支持性"""
|
||||
if not self.file_path.exists():
|
||||
raise FileNotFoundError(f"文件不存在: {self.file_path}")
|
||||
if self.file_suffix not in self.SUPPORTED_FORMATS:
|
||||
raise ValueError(
|
||||
f"不支持的格式: {self.file_suffix} | 支持格式: {list(self.SUPPORTED_FORMATS.keys())}"
|
||||
)
|
||||
|
||||
def load(
|
||||
self,
|
||||
file_format: Optional[str] = None,
|
||||
**loader_kwargs
|
||||
) -> List[Document]:
|
||||
"""
|
||||
加载文档并返回 LangChain Document 列表
|
||||
:param file_format: 手动指定格式(如 ".pdf"),默认自动识别
|
||||
:param loader_kwargs: 传递给具体加载器的参数(如 JSONLoader 的 jq_schema)
|
||||
:return: List[Document]
|
||||
"""
|
||||
# 确定目标格式
|
||||
target_format = file_format.lower() if file_format else self.file_suffix
|
||||
loader_cls = self.SUPPORTED_FORMATS[target_format]
|
||||
|
||||
# 加载器默认参数优化
|
||||
loader_kwargs = self._set_default_kwargs(loader_cls, loader_kwargs)
|
||||
|
||||
# 初始化并加载
|
||||
loader = loader_cls(str(self.file_path), **loader_kwargs)
|
||||
return loader.load()
|
||||
|
||||
@staticmethod
|
||||
def _set_default_kwargs(loader_cls, kwargs: dict) -> dict:
|
||||
"""为不同加载器设置默认参数,简化调用"""
|
||||
if loader_cls == JSONLoader and "jq_schema" not in kwargs:
|
||||
kwargs.setdefault("jq_schema", ".")
|
||||
kwargs.setdefault("text_content", False)
|
||||
if loader_cls == CSVLoader and "csv_args" not in kwargs:
|
||||
kwargs["csv_args"] = {"delimiter": ","}
|
||||
if loader_cls == UnstructuredExcelLoader:
|
||||
# Excel 文件使用 "single" 模式,避免生成过多无意义的元素
|
||||
# "elements" 模式会为每个单元格生成单独的文档,可能导致大量空内容
|
||||
if "mode" not in kwargs:
|
||||
kwargs.setdefault("mode", "single")
|
||||
# 确保加载表格结构
|
||||
kwargs.setdefault("include_header", True)
|
||||
if loader_cls == UnstructuredFileLoader and "mode" not in kwargs:
|
||||
kwargs.setdefault("mode", "elements")
|
||||
return kwargs
|
||||
|
||||
|
||||
# 文档加载器便捷函数
|
||||
def load_documents(
|
||||
file_path: Union[str, Path],
|
||||
file_format: Optional[str] = None,
|
||||
**loader_kwargs
|
||||
) -> List[Document]:
|
||||
"""快速加载文档的便捷函数"""
|
||||
loader = UniversalDocLoader(file_path)
|
||||
return loader.load(file_format=file_format, **loader_kwargs)
|
||||
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.db.models.base_entity import LineageNode, LineageEdge
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
class LineageService:
|
||||
"""血缘服务(Python 版)"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def generate_graph(
|
||||
self,
|
||||
from_node: Optional[LineageNode],
|
||||
edge: Optional[LineageEdge],
|
||||
to_node: Optional[LineageNode]
|
||||
) -> None:
|
||||
"""
|
||||
生成血缘图
|
||||
|
||||
Args:
|
||||
from_node: 源节点
|
||||
edge: 边
|
||||
to_node: 目的节点
|
||||
"""
|
||||
# 1. 如果 from_node 为空,直接返回不做任何处理
|
||||
if from_node is None:
|
||||
return
|
||||
|
||||
# 2. 如果 from_node 有值,检查 from_node 是否存在
|
||||
existing_from = await self.get_node_by_id(from_node.id) if from_node.id else None
|
||||
|
||||
if existing_from is None:
|
||||
# from_node 不存在,创建 from_node,记录 from_graph_id
|
||||
from_graph_id = str(uuid4())
|
||||
if not from_node.id:
|
||||
from_node.id = str(uuid4())
|
||||
from_node.graph_id = from_graph_id
|
||||
self.db.add(from_node)
|
||||
await self.db.flush()
|
||||
else:
|
||||
# from_node 存在,记录 from_graph_id
|
||||
from_graph_id = existing_from.graph_id
|
||||
|
||||
# 处理 edge 和 to_node
|
||||
await self._generate_graph(edge, to_node, from_graph_id)
|
||||
|
||||
async def _generate_graph(
|
||||
self,
|
||||
edge: Optional[LineageEdge],
|
||||
to_node: Optional[LineageNode],
|
||||
graph_id: Optional[str]
|
||||
) -> None:
|
||||
# 无 edge,直接返回
|
||||
if edge is None:
|
||||
return
|
||||
|
||||
# 有 edge,处理 edge,检查是否有 to_node
|
||||
await self._handle_lineage_edge(graph_id, edge)
|
||||
if to_node is None:
|
||||
return
|
||||
|
||||
# 有 to_node,检查 to_node 是否存在
|
||||
existing_to = await self.get_node_by_id(to_node.id) if to_node.id else None
|
||||
|
||||
if existing_to is None:
|
||||
# to_node 不存在,创建 to_node 后结束
|
||||
if not to_node.id:
|
||||
to_node.id = str(uuid4())
|
||||
to_node.graph_id = graph_id
|
||||
self.db.add(to_node)
|
||||
await self.db.flush()
|
||||
else:
|
||||
# to_node 存在,将 from_node 所在的图并入 to_node 所在的图后结束
|
||||
await self._merge_graph(graph_id, existing_to.graph_id)
|
||||
|
||||
async def _handle_lineage_edge(self, graph_id: Optional[str], edge: LineageEdge) -> None:
|
||||
stmt = select(LineageEdge).where(
|
||||
LineageEdge.graph_id == graph_id,
|
||||
LineageEdge.from_node_id == edge.from_node_id,
|
||||
LineageEdge.to_node_id == edge.to_node_id
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing is None:
|
||||
if not edge.id:
|
||||
edge.id = str(uuid4())
|
||||
edge.graph_id = graph_id
|
||||
self.db.add(edge)
|
||||
await self.db.flush()
|
||||
else:
|
||||
edge.id = existing.id
|
||||
if edge.graph_id is None:
|
||||
edge.graph_id = existing.graph_id
|
||||
# 覆盖更新现有记录
|
||||
existing.graph_id = edge.graph_id
|
||||
existing.process_id = edge.process_id
|
||||
existing.edge_type = edge.edge_type
|
||||
existing.name = edge.name
|
||||
existing.description = edge.description
|
||||
existing.edge_metadata = edge.edge_metadata
|
||||
existing.from_node_id = edge.from_node_id
|
||||
existing.to_node_id = edge.to_node_id
|
||||
await self.db.flush()
|
||||
|
||||
async def _merge_graph(self, from_graph_id: Optional[str], to_graph_id: Optional[str]) -> None:
|
||||
if not from_graph_id or not to_graph_id or from_graph_id == to_graph_id:
|
||||
return
|
||||
|
||||
from_nodes = await self.get_nodes_by_graph_id(from_graph_id)
|
||||
to_nodes = await self.get_nodes_by_graph_id(to_graph_id)
|
||||
|
||||
# choose smaller graph as source, larger as target
|
||||
source_graph_id = from_graph_id if len(from_nodes) <= len(to_nodes) else to_graph_id
|
||||
target_graph_id = to_graph_id if source_graph_id == from_graph_id else from_graph_id
|
||||
source_nodes = from_nodes if source_graph_id == from_graph_id else to_nodes
|
||||
|
||||
# update nodes' graph_id
|
||||
for node in source_nodes:
|
||||
if node is None:
|
||||
continue
|
||||
node.graph_id = target_graph_id
|
||||
self.db.add(node)
|
||||
|
||||
# update edges' graph_id (edges belonging to the source graph)
|
||||
edges = await self.get_edges_by_graph_id(source_graph_id)
|
||||
for edge in edges:
|
||||
if edge is None:
|
||||
continue
|
||||
edge.graph_id = target_graph_id
|
||||
self.db.add(edge)
|
||||
|
||||
await self.db.flush()
|
||||
|
||||
async def get_nodes_by_graph_id(self, graph_id: str) -> List[LineageNode]:
|
||||
"""从图ID获取图的节点列表"""
|
||||
stmt = select(LineageNode).where(LineageNode.graph_id == graph_id)
|
||||
result = await self.db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_edges_by_graph_id(self, graph_id: str) -> List[LineageEdge]:
|
||||
"""从图ID获取图的边列表"""
|
||||
stmt = select(LineageEdge).where(LineageEdge.graph_id == graph_id)
|
||||
result = await self.db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_node_by_id(self, node_id: str) -> Optional[LineageNode]:
|
||||
"""从节点ID获取节点"""
|
||||
return await self.db.get(LineageNode, node_id)
|
||||
@@ -0,0 +1,169 @@
|
||||
import os
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from langchain_core.documents import Document
|
||||
from langchain_text_splitters import (
|
||||
RecursiveCharacterTextSplitter,
|
||||
MarkdownHeaderTextSplitter
|
||||
)
|
||||
|
||||
|
||||
class DocumentSplitter:
|
||||
"""
|
||||
文档分割器类 - 增强版,优先通过元数据识别文档类型
|
||||
核心特性:
|
||||
1. 优先从metadata的source字段(文件扩展名)识别Markdown
|
||||
2. 元数据缺失时,通过内容特征降级检测
|
||||
3. 支持CJK(中日韩)语言优化
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chunk_size: int = 2000,
|
||||
chunk_overlap: int = 200,
|
||||
is_cjk_language: bool = True,
|
||||
markdown_headers: Optional[List[Tuple[str, str]]] = None
|
||||
):
|
||||
"""
|
||||
初始化文档分割器
|
||||
|
||||
Args:
|
||||
chunk_size: 每个文本块的最大长度(默认2000字符)
|
||||
chunk_overlap: 文本块之间的重叠长度(默认200字符)
|
||||
is_cjk_language: 是否处理中日韩等无词边界语言(默认True)
|
||||
markdown_headers: Markdown标题分割规则(默认:#/##/###/####)
|
||||
"""
|
||||
self.chunk_size = chunk_size
|
||||
self.chunk_overlap = chunk_overlap
|
||||
self.is_cjk_language = is_cjk_language
|
||||
|
||||
# 默认Markdown标题分割规则
|
||||
self.markdown_headers = markdown_headers or [
|
||||
("#", "header_1"),
|
||||
("##", "header_2"),
|
||||
("###", "header_3"),
|
||||
("####", "header_4"),
|
||||
]
|
||||
|
||||
# 初始化基础文本分割器
|
||||
self.text_splitter = self._create_text_splitter()
|
||||
|
||||
def _create_text_splitter(self) -> RecursiveCharacterTextSplitter:
|
||||
"""创建递归字符分割器(内部方法)"""
|
||||
# 优化后的CJK分隔符列表(修复语法错误,调整优先级)
|
||||
if self.is_cjk_language:
|
||||
separators = [
|
||||
"\n\n", "\n", # 段落/换行(最高优先级)
|
||||
"。", ".", # 句号(中文/英文)
|
||||
"!", "!", # 感叹号(中文/英文)
|
||||
"?", "?", # 问号(中文/英文)
|
||||
";", ";", # 分号(中文/英文)
|
||||
",", ",", # 逗号(中文/英文)
|
||||
"、", # 顿号(中文)
|
||||
":", ":", # 冒号(中文/英文)
|
||||
" ", # 空格
|
||||
"\u200b", "", # 零宽空格/兜底
|
||||
]
|
||||
else:
|
||||
separators = ["\n\n", "\n", " ", ".", "!", "?", ";", ":", ",", ""]
|
||||
|
||||
return RecursiveCharacterTextSplitter(
|
||||
chunk_size=self.chunk_size,
|
||||
chunk_overlap=self.chunk_overlap,
|
||||
separators=separators,
|
||||
length_function=len,
|
||||
is_separator_regex=False
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_markdown(doc: Document) -> bool:
|
||||
"""
|
||||
优先从元数据判断是否为Markdown
|
||||
规则:检查metadata中的source字段扩展名是否为.md/.markdown/.mdx等
|
||||
"""
|
||||
# 获取source字段(忽略大小写)
|
||||
source = doc.metadata.get("source", "").lower()
|
||||
if not source:
|
||||
return False
|
||||
|
||||
# 获取文件扩展名
|
||||
ext = os.path.splitext(source)[-1].lower()
|
||||
# Markdown常见扩展名列表
|
||||
md_ext = [".md", ".markdown", ".mdx", ".mkd", ".mkdown"]
|
||||
return ext in md_ext
|
||||
|
||||
def split(self, documents: List[Document], is_markdown: bool = False) -> List[Document]:
|
||||
"""
|
||||
核心分割方法
|
||||
|
||||
Args:
|
||||
documents: 待分割的Document列表
|
||||
is_markdown: 是否为Markdown文档(默认False)
|
||||
|
||||
Returns:
|
||||
分割后的Document列表
|
||||
"""
|
||||
if not documents:
|
||||
return []
|
||||
|
||||
# Markdown文档处理:先按标题分割,再按字符分割
|
||||
if is_markdown:
|
||||
# 初始化Markdown标题分割器
|
||||
md_splitter = MarkdownHeaderTextSplitter(
|
||||
headers_to_split_on=self.markdown_headers,
|
||||
strip_headers=True,
|
||||
return_each_line=False
|
||||
)
|
||||
|
||||
# 按标题分割并继承元数据
|
||||
md_chunks = []
|
||||
for doc in documents:
|
||||
chunks = md_splitter.split_text(doc.page_content)
|
||||
for chunk in chunks:
|
||||
chunk.metadata.update(doc.metadata)
|
||||
md_chunks.extend(chunks)
|
||||
|
||||
# 对标题分割后的内容进行字符分割
|
||||
final_chunks = self.text_splitter.split_documents(md_chunks)
|
||||
|
||||
# 普通文本直接分割
|
||||
else:
|
||||
final_chunks = self.text_splitter.split_documents(documents)
|
||||
|
||||
return final_chunks
|
||||
|
||||
# 核心自动分割方法(元数据优先)
|
||||
@classmethod
|
||||
def auto_split(
|
||||
cls,
|
||||
documents: List[Document],
|
||||
chunk_size: int = 2000,
|
||||
chunk_overlap: int = 200
|
||||
) -> List[Document]:
|
||||
"""
|
||||
极简快捷方法:自动识别文档类型并分割(元数据优先)
|
||||
仅需传入3个参数,无需初始化类实例
|
||||
|
||||
Args:
|
||||
documents: 待分割的Document列表
|
||||
chunk_size: 每个文本块的最大长度(默认2000字符)
|
||||
chunk_overlap: 文本块之间的重叠长度(默认200字符)
|
||||
|
||||
Returns:
|
||||
分割后的Document列表
|
||||
"""
|
||||
if not documents:
|
||||
return []
|
||||
|
||||
# 初始化分割器实例(使用CJK默认优化)
|
||||
splitter = cls(
|
||||
chunk_size=chunk_size,
|
||||
chunk_overlap=chunk_overlap,
|
||||
is_cjk_language=True
|
||||
)
|
||||
|
||||
# 自动检测文档类型(元数据优先)
|
||||
is_md = splitter._is_markdown(documents[0])
|
||||
|
||||
# 根据检测结果选择分割方式
|
||||
return splitter.split(documents, is_markdown=is_md)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
File Models
|
||||
文件相关模型定义
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ChunkUploadPreRequestDto(BaseModel):
|
||||
"""分片上传预请求DTO"""
|
||||
id: str = Field(..., description="请求ID")
|
||||
total_file_num: int = Field(..., description="总文件数", ge=1)
|
||||
uploaded_file_num: Optional[int] = Field(None, description="已上传文件数", ge=0)
|
||||
upload_path: str = Field(..., description="文件路径")
|
||||
timeout: Optional[datetime] = Field(None, description="上传请求超时时间")
|
||||
service_id: Optional[str] = Field(None, description="上传请求所属服务ID")
|
||||
check_info: Optional[str] = Field(None, description="业务信息")
|
||||
|
||||
|
||||
class ChunkUploadRequestDto(BaseModel):
|
||||
"""分片上传请求DTO"""
|
||||
req_id: str = Field(..., description="预上传返回的ID")
|
||||
file_no: int = Field(1, description="文件编号", ge=1)
|
||||
file_name: str = Field(..., description="文件名称")
|
||||
total_chunk_num: int = Field(1, description="总分块数量", ge=1)
|
||||
chunk_no: int = Field(1, description="当前分块编号", ge=1)
|
||||
file_size: Optional[int] = Field(None, description="文件大小", ge=0)
|
||||
check_sum_hex: Optional[str] = Field(None, description="文件校验和(十六进制字符串)")
|
||||
|
||||
|
||||
class FileUploadResult(BaseModel):
|
||||
"""文件上传结果"""
|
||||
is_all_files_uploaded: bool = Field(..., description="是否所有文件已上传")
|
||||
check_info: Optional[str] = Field(None, description="业务上传信息")
|
||||
saved_file_path: Optional[str] = Field(None, description="保存的文件路径")
|
||||
file_name: str = Field(..., description="文件名称")
|
||||
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
File Service
|
||||
文件服务,处理文件上传、分片上传等功能
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.db.models.chunk_upload import ChunkUploadPreRequest
|
||||
from app.module.shared.chunk_upload_repository import ChunkUploadRepository
|
||||
from app.module.shared.chunks_saver import ChunksSaver
|
||||
from app.module.shared.file_models import (
|
||||
ChunkUploadRequestDto,
|
||||
FileUploadResult,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class FileService:
|
||||
"""文件服务"""
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 120
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chunk_upload_repo: ChunkUploadRepository,
|
||||
):
|
||||
self.chunk_upload_repo = chunk_upload_repo
|
||||
|
||||
async def pre_upload(
|
||||
self,
|
||||
upload_path: str,
|
||||
service_id: str,
|
||||
db_session,
|
||||
check_info: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
预上传
|
||||
|
||||
Args:
|
||||
upload_path: 上传路径
|
||||
service_id: 服务ID
|
||||
check_info: 业务信息
|
||||
|
||||
Returns:
|
||||
预上传请求ID
|
||||
"""
|
||||
req_id = str(uuid.uuid4())
|
||||
timeout = datetime.utcnow().replace(
|
||||
microsecond=0
|
||||
) + timedelta(seconds=self.DEFAULT_TIMEOUT_SECONDS)
|
||||
|
||||
pre_request = ChunkUploadPreRequest(
|
||||
id=req_id,
|
||||
total_file_num=1,
|
||||
uploaded_file_num=0,
|
||||
upload_path=upload_path,
|
||||
timeout=timeout,
|
||||
service_id=service_id,
|
||||
check_info=check_info,
|
||||
)
|
||||
|
||||
await self.chunk_upload_repo.insert(pre_request, db_session)
|
||||
return req_id
|
||||
|
||||
async def chunk_upload(
|
||||
self,
|
||||
upload_request: ChunkUploadRequestDto,
|
||||
upload_path: str,
|
||||
file_content: bytes,
|
||||
db_session,
|
||||
) -> FileUploadResult:
|
||||
"""
|
||||
分片上传
|
||||
|
||||
Args:
|
||||
upload_request: 上传请求
|
||||
upload_path: 上传路径
|
||||
file_content: 文件内容
|
||||
db_session: 数据库会话
|
||||
|
||||
Returns:
|
||||
上传结果
|
||||
"""
|
||||
upload_request.file_size = len(file_content)
|
||||
|
||||
pre_request = await self.chunk_upload_repo.find_by_id(
|
||||
upload_request.req_id, db_session
|
||||
)
|
||||
|
||||
if pre_request is None:
|
||||
logger.error(f"pre-upload request not found: {upload_request.req_id}")
|
||||
raise ValueError("Pre-upload request not found")
|
||||
|
||||
if pre_request.is_upload_complete():
|
||||
logger.error(f"upload already complete: {upload_request.req_id}")
|
||||
raise ValueError("Upload already complete")
|
||||
|
||||
if pre_request.is_request_timeout():
|
||||
logger.error(f"upload request timeout: {upload_request.req_id}")
|
||||
raise ValueError("Upload request timeout")
|
||||
|
||||
saved_file_path = None
|
||||
|
||||
if upload_request.total_chunk_num > 1:
|
||||
saved_file_path = await self._upload_chunk(
|
||||
upload_request, pre_request, upload_path, file_content
|
||||
)
|
||||
else:
|
||||
saved_file_path = await self._upload_file(
|
||||
upload_request, pre_request, upload_path, file_content
|
||||
)
|
||||
|
||||
update_count = await self.chunk_upload_repo.update(pre_request, db_session)
|
||||
|
||||
if update_count == 0:
|
||||
logger.error(f"failed to update pre-request: {upload_request.req_id}")
|
||||
raise ValueError("Failed to update pre-upload request")
|
||||
|
||||
is_finish = pre_request.uploaded_file_num == pre_request.total_file_num
|
||||
|
||||
if is_finish:
|
||||
temp_dir = os.path.join(
|
||||
upload_path,
|
||||
ChunksSaver.TEMP_DIR_NAME_FORMAT % pre_request.id
|
||||
)
|
||||
try:
|
||||
ChunksSaver.delete_folder(temp_dir)
|
||||
except Exception as e:
|
||||
logger.warning(f"failed to delete temp dir: {temp_dir}, error: {e}")
|
||||
|
||||
await self.chunk_upload_repo.delete_by_id(pre_request.id, db_session)
|
||||
|
||||
return FileUploadResult(
|
||||
is_all_files_uploaded=is_finish,
|
||||
check_info=pre_request.check_info,
|
||||
saved_file_path=str(saved_file_path) if saved_file_path else None,
|
||||
file_name=upload_request.file_name,
|
||||
)
|
||||
|
||||
async def _upload_file(
|
||||
self,
|
||||
upload_request: ChunkUploadRequestDto,
|
||||
pre_request: ChunkUploadPreRequest,
|
||||
upload_path: str,
|
||||
file_content: bytes
|
||||
) -> Path:
|
||||
"""上传单文件"""
|
||||
saved_file = ChunksSaver.save_file(
|
||||
upload_request, upload_path, file_content
|
||||
)
|
||||
|
||||
pre_request.timeout = datetime.utcnow().replace(
|
||||
microsecond=0
|
||||
) + timedelta(seconds=self.DEFAULT_TIMEOUT_SECONDS)
|
||||
pre_request.increment_uploaded_file_num()
|
||||
|
||||
return saved_file
|
||||
|
||||
async def _upload_chunk(
|
||||
self,
|
||||
upload_request: ChunkUploadRequestDto,
|
||||
pre_request: ChunkUploadPreRequest,
|
||||
upload_path: str,
|
||||
file_content: bytes
|
||||
) -> Optional[Path]:
|
||||
"""上传分片"""
|
||||
saved_file = ChunksSaver.save(
|
||||
upload_request, pre_request.id, upload_path, file_content
|
||||
)
|
||||
|
||||
if saved_file is not None:
|
||||
pre_request.increment_uploaded_file_num()
|
||||
return saved_file
|
||||
|
||||
pre_request.timeout = datetime.utcnow().replace(
|
||||
microsecond=0
|
||||
) + timedelta(seconds=self.DEFAULT_TIMEOUT_SECONDS)
|
||||
return None
|
||||
@@ -0,0 +1,7 @@
|
||||
# app/core/llm/__init__.py
|
||||
"""
|
||||
LangChain 模型工厂:统一创建 Chat、Embedding 及健康检查,便于各模块复用。
|
||||
"""
|
||||
from .factory import LLMFactory
|
||||
|
||||
__all__ = ["LLMFactory"]
|
||||
@@ -0,0 +1,124 @@
|
||||
# app/module/shared/llm/factory.py
|
||||
"""
|
||||
LangChain 模型工厂:基于 OpenAI 兼容接口封装 Chat / Embedding 的创建、健康检查与同步调用。
|
||||
- DashScope compatible‑mode:重写embedding逻辑,规避tiktoken token数组输入问题
|
||||
- 其他OpenAI兼容服务商:完全使用原生 OpenAIEmbeddings / ChatOpenAI
|
||||
"""
|
||||
from typing import Literal, List, Optional
|
||||
|
||||
import httpx
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
from langchain_core.embeddings import Embeddings
|
||||
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
|
||||
from pydantic import SecretStr
|
||||
|
||||
|
||||
class DashScopeOpenAIEmbeddings(OpenAIEmbeddings, Embeddings):
|
||||
"""
|
||||
仅用于阿里云DashScope compatible‑mode
|
||||
1. 绕过原生tiktoken逻辑,直接传入原始字符串,不传入token数字数组
|
||||
2. 适配新版openai SDK返回Pydantic Embedding对象,使用属性 .embedding
|
||||
"""
|
||||
|
||||
def _get_len_safe_embeddings(self, texts: List[str], *, engine: str, **kwargs) -> List[List[float]]:
|
||||
batch_size = self.chunk_size
|
||||
embeddings: List[List[float]] = []
|
||||
for i in range(0, len(texts), batch_size):
|
||||
batch = texts[i:i + batch_size]
|
||||
resp = self.client.create(
|
||||
input=batch,
|
||||
model=self.model,
|
||||
encoding_format="float",
|
||||
)
|
||||
embeddings.extend([data.embedding for data in resp.data])
|
||||
return embeddings
|
||||
|
||||
|
||||
class LLMFactory:
|
||||
@staticmethod
|
||||
def _is_dashscope_compatible(base_url: Optional[str]) -> bool:
|
||||
if not base_url:
|
||||
return False
|
||||
return "dashscope.aliyuncs.com/compatible-mode" in base_url
|
||||
|
||||
@staticmethod
|
||||
def create_chat(
|
||||
model_name: str,
|
||||
base_url: str,
|
||||
api_key: str | None = None,
|
||||
) -> BaseChatModel:
|
||||
"""创建对话模型,兼容 OpenAI 及任意 base_url 的 OpenAI 兼容服务。"""
|
||||
# DashScope容器环境关闭SSL校验;其余服务商使用默认httpx客户端(开启证书校验)
|
||||
if LLMFactory._is_dashscope_compatible(base_url):
|
||||
http_client = httpx.Client(verify=False)
|
||||
else:
|
||||
http_client = None
|
||||
|
||||
return ChatOpenAI(
|
||||
model=model_name,
|
||||
base_url=base_url or None,
|
||||
api_key=SecretStr(api_key or ""),
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_embedding(
|
||||
model_name: str,
|
||||
base_url: str,
|
||||
api_key: str | None = None,
|
||||
) -> Embeddings:
|
||||
is_dashscope = LLMFactory._is_dashscope_compatible(base_url)
|
||||
|
||||
# DashScope才关闭SSL校验,其他服务商走默认SSL校验
|
||||
if is_dashscope:
|
||||
http_client = httpx.Client(verify=False)
|
||||
else:
|
||||
http_client = None
|
||||
|
||||
kwargs = {
|
||||
"model": model_name,
|
||||
"base_url": base_url or None,
|
||||
"api_key": SecretStr(api_key or ""),
|
||||
"http_client": http_client,
|
||||
}
|
||||
|
||||
# 仅DashScope注入encoding_format="float",其他服务商不强制注入,避免参数不兼容
|
||||
if is_dashscope:
|
||||
import inspect
|
||||
sig = inspect.signature(OpenAIEmbeddings.__init__)
|
||||
if "encoding_format" in sig.parameters:
|
||||
kwargs["encoding_format"] = "float"
|
||||
return DashScopeOpenAIEmbeddings(**kwargs)
|
||||
|
||||
# 其余所有OpenAI兼容模型,走原生实现
|
||||
return OpenAIEmbeddings(**kwargs)
|
||||
|
||||
@staticmethod
|
||||
def check_health(
|
||||
model_name: str,
|
||||
base_url: str,
|
||||
api_key: str | None,
|
||||
model_type: Literal["CHAT", "EMBEDDING"] | str,
|
||||
) -> None:
|
||||
"""对配置做一次最小化调用进行健康检查,失败则抛出。"""
|
||||
if model_type == "CHAT":
|
||||
model = LLMFactory.create_chat(model_name, base_url, api_key)
|
||||
model.invoke("hello")
|
||||
else:
|
||||
model = LLMFactory.create_embedding(model_name, base_url, api_key)
|
||||
model.embed_query("text")
|
||||
|
||||
@staticmethod
|
||||
def get_embedding_dimension(
|
||||
model_name: str,
|
||||
base_url: str,
|
||||
api_key: str | None = None,
|
||||
) -> int:
|
||||
"""创建 Embedding 模型并返回向量维度。"""
|
||||
emb = LLMFactory.create_embedding(model_name, base_url, api_key)
|
||||
return len(emb.embed_query("text"))
|
||||
|
||||
@staticmethod
|
||||
def invoke_sync(chat_model: BaseChatModel, prompt: str) -> str:
|
||||
"""同步调用对话模型并返回 content,供 run_in_executor 等场景使用。"""
|
||||
return chat_model.invoke(prompt).content
|
||||
@@ -0,0 +1,57 @@
|
||||
# 代码内容与原文件基本一致,确保它是通用的
|
||||
import httpx
|
||||
from typing import Optional
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# xuhuanqing
|
||||
class RuntimeClient:
|
||||
"""HTTP client for communicating with deepdata-runtime service"""
|
||||
|
||||
def __init__(self, base_url: str = "http://deepdata-runtime:8081"):
|
||||
self.base_url = base_url
|
||||
self.client = httpx.AsyncClient(timeout=60.0)
|
||||
|
||||
async def submit_task(self, task_id: str, retry_count: int = 0) -> bool:
|
||||
"""Submit task to runtime executor (通用接口)"""
|
||||
try:
|
||||
url = f"{self.base_url}/api/task/{task_id}/submit"
|
||||
# 注意:虽然 json payload 目前是 {retry_count},
|
||||
# 但 Runtime 接收的是一个通用的 JSON,未来可以扩展增加 executor_type 等字段
|
||||
response = await self.client.post(url, json={"retry_count": retry_count})
|
||||
response.raise_for_status()
|
||||
logger.info(f"Task {task_id} submitted to Runtime successfully.")
|
||||
return True
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Failed to submit task {task_id} to Runtime: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error submitting task {task_id}: {e}")
|
||||
return False
|
||||
|
||||
async def stop_task(self, task_id: str) -> bool:
|
||||
"""Stop running task"""
|
||||
try:
|
||||
url = f"{self.base_url}/api/task/{task_id}/stop"
|
||||
response = await self.client.post(url)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop task {task_id}: {e}")
|
||||
return False
|
||||
|
||||
async def get_task_status(self, task_id: str) -> Optional[dict]:
|
||||
"""Get task execution status from runtime"""
|
||||
try:
|
||||
url = f"{self.base_url}/api/task/{task_id}/status"
|
||||
response = await self.client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get task status {task_id}: {e}")
|
||||
return None
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client"""
|
||||
await self.client.aclose()
|
||||
@@ -0,0 +1,3 @@
|
||||
from .scheduler import Scheduler
|
||||
|
||||
__all__ = ["Scheduler"]
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class Scheduler:
|
||||
def __init__(self, name: str = "scheduler", timezone: Optional[str] = None) -> None:
|
||||
self._name = name
|
||||
self._scheduler: Optional[AsyncIOScheduler] = None
|
||||
# 使用指定的时区,如果没有指定则使用本地时区
|
||||
self._timezone = pytz.timezone(timezone) if timezone else None
|
||||
|
||||
def start(self) -> AsyncIOScheduler:
|
||||
if self._scheduler is None:
|
||||
# 配置调度器使用本地时区
|
||||
from tzlocal import get_localzone
|
||||
local_tz = get_localzone()
|
||||
self._scheduler = AsyncIOScheduler(timezone=local_tz)
|
||||
self._scheduler.start()
|
||||
logger.info(f"{self._name} started with timezone: {local_tz}")
|
||||
return self._scheduler
|
||||
|
||||
def shutdown(self) -> None:
|
||||
if self._scheduler is not None:
|
||||
self._scheduler.shutdown(wait=False)
|
||||
self._scheduler = None
|
||||
logger.info(f"{self._name} stopped")
|
||||
|
||||
def add_cron_job(
|
||||
self,
|
||||
job_id: str,
|
||||
cron_expression: str,
|
||||
func: Callable[..., Any],
|
||||
args: Optional[list[Any]] = None,
|
||||
kwargs: Optional[dict[str, Any]] = None,
|
||||
**job_kwargs: Any,
|
||||
) -> None:
|
||||
scheduler = self._get_scheduler()
|
||||
# 使用调度器的时区创建 CronTrigger
|
||||
trigger = CronTrigger.from_crontab(cron_expression, timezone=scheduler.timezone)
|
||||
scheduler.add_job(
|
||||
func,
|
||||
trigger=trigger,
|
||||
args=args or [],
|
||||
kwargs=kwargs or {},
|
||||
id=job_id,
|
||||
replace_existing=job_kwargs.pop("replace_existing", True),
|
||||
max_instances=job_kwargs.pop("max_instances", 1),
|
||||
coalesce=job_kwargs.pop("coalesce", True),
|
||||
misfire_grace_time=job_kwargs.pop("misfire_grace_time", 60),
|
||||
**job_kwargs,
|
||||
)
|
||||
|
||||
def add_interval_job(
|
||||
self,
|
||||
job_id: str,
|
||||
seconds: int,
|
||||
func: Callable[..., Any],
|
||||
args: Optional[list[Any]] = None,
|
||||
kwargs: Optional[dict[str, Any]] = None,
|
||||
**job_kwargs: Any,
|
||||
) -> None:
|
||||
scheduler = self._get_scheduler()
|
||||
trigger = IntervalTrigger(seconds=seconds, timezone=scheduler.timezone)
|
||||
scheduler.add_job(
|
||||
func,
|
||||
trigger=trigger,
|
||||
args=args or [],
|
||||
kwargs=kwargs or {},
|
||||
id=job_id,
|
||||
replace_existing=job_kwargs.pop("replace_existing", True),
|
||||
max_instances=job_kwargs.pop("max_instances", 1),
|
||||
coalesce=job_kwargs.pop("coalesce", True),
|
||||
misfire_grace_time=job_kwargs.pop("misfire_grace_time", 60),
|
||||
**job_kwargs,
|
||||
)
|
||||
|
||||
def remove_job(self, job_id: str) -> None:
|
||||
if self._scheduler is None:
|
||||
return
|
||||
if self._scheduler.get_job(job_id):
|
||||
self._scheduler.remove_job(job_id)
|
||||
|
||||
def has_job(self, job_id: str) -> bool:
|
||||
if self._scheduler is None:
|
||||
return False
|
||||
return self._scheduler.get_job(job_id) is not None
|
||||
|
||||
def reschedule_job(
|
||||
self,
|
||||
job_id: str,
|
||||
cron_expression: str,
|
||||
func: Callable[..., Any],
|
||||
args: Optional[list[Any]] = None,
|
||||
kwargs: Optional[dict[str, Any]] = None,
|
||||
**job_kwargs: Any,
|
||||
) -> None:
|
||||
self.remove_job(job_id)
|
||||
self.add_cron_job(
|
||||
job_id=job_id,
|
||||
cron_expression=cron_expression,
|
||||
func=func,
|
||||
args=args,
|
||||
kwargs=kwargs,
|
||||
**job_kwargs,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def validate_cron_expression(cron_expression: str) -> None:
|
||||
CronTrigger.from_crontab(cron_expression)
|
||||
|
||||
def _get_scheduler(self) -> AsyncIOScheduler:
|
||||
if self._scheduler is None:
|
||||
raise RuntimeError(f"{self._name} not initialized")
|
||||
return self._scheduler
|
||||
@@ -0,0 +1,19 @@
|
||||
from .common import (
|
||||
BaseResponseModel,
|
||||
StandardResponse,
|
||||
PaginatedData,
|
||||
TaskStatus
|
||||
)
|
||||
from .lineage import (
|
||||
NodeType,
|
||||
EdgeType
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BaseResponseModel",
|
||||
"StandardResponse",
|
||||
"PaginatedData",
|
||||
"TaskStatus",
|
||||
"NodeType",
|
||||
"EdgeType"
|
||||
]
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
通用响应模型
|
||||
"""
|
||||
from typing import Generic, TypeVar, List
|
||||
from pydantic import BaseModel, Field
|
||||
from enum import Enum
|
||||
|
||||
# 定义泛型类型变量
|
||||
T = TypeVar('T')
|
||||
|
||||
# 定义一个将 snake_case 转换为 camelCase 的函数
|
||||
def to_camel(string: str) -> str:
|
||||
"""将 snake_case 字符串转换为 camelCase"""
|
||||
components = string.split('_')
|
||||
# 首字母小写,其余单词首字母大写
|
||||
return components[0] + ''.join(x.title() for x in components[1:])
|
||||
|
||||
class BaseResponseModel(BaseModel):
|
||||
"""基础响应模型,启用别名生成器"""
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
alias_generator = to_camel
|
||||
|
||||
class StandardResponse(BaseResponseModel, Generic[T]):
|
||||
"""
|
||||
标准API响应格式
|
||||
|
||||
所有API端点(包括错误响应)都应返回此格式
|
||||
"""
|
||||
code: str = Field(..., description="业务状态码(字符串)。成功使用 '0',错误使用 '{module}.{sequence}' 格式(如 'rag.001')")
|
||||
message: str = Field(..., description="响应消息")
|
||||
data: T = Field(default=None, description="响应数据")
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
alias_generator = to_camel
|
||||
|
||||
|
||||
class ResponseCode(str, Enum):
|
||||
"""通用响应码枚举"""
|
||||
|
||||
# 成功响应
|
||||
SUCCESS = "0" # 操作成功
|
||||
|
||||
# 通用错误
|
||||
BAD_REQUEST = "common.400" # 错误的请求
|
||||
UNAUTHORIZED = "common.401" # 未授权
|
||||
FORBIDDEN = "common.403" # 禁止访问
|
||||
NOT_FOUND = "common.404" # 资源未找到
|
||||
VALIDATION_ERROR = "common.422" # 验证错误
|
||||
INTERNAL_ERROR = "common.500" # 服务器内部错误
|
||||
SERVICE_UNAVAILABLE = "common.503" # 服务不可用
|
||||
|
||||
class PaginatedData(BaseResponseModel, Generic[T]):
|
||||
"""分页数据容器"""
|
||||
page: int = Field(..., description="当前页码(从1开始)")
|
||||
size: int = Field(..., description="页大小")
|
||||
total_elements: int = Field(..., description="总条数")
|
||||
total_pages: int = Field(..., description="总页数")
|
||||
content: List[T] = Field(..., description="当前页数据")
|
||||
|
||||
class TaskStatus(Enum):
|
||||
PENDING = "PENDING"
|
||||
RUNNING = "RUNNING"
|
||||
COMPLETED = "COMPLETED"
|
||||
FAILED = "FAILED"
|
||||
@@ -0,0 +1,14 @@
|
||||
from enum import Enum
|
||||
|
||||
class NodeType(str, Enum):
|
||||
DATASOURCE = "DATASOURCE"
|
||||
DATASET = "DATASET"
|
||||
KNOWLEDGE_BASE = "KNOWLEDGE_BASE"
|
||||
MODEL = "MODEL"
|
||||
|
||||
class EdgeType(str, Enum):
|
||||
DATA_COLLECTION = "DATA_COLLECTION"
|
||||
DATA_CLEANING = "DATA_CLEANING"
|
||||
DATA_LABELING = "DATA_LABELING"
|
||||
DATA_SYNTHESIS = "DATA_SYNTHESIS"
|
||||
DATA_RATIO = "DATA_RATIO"
|
||||
@@ -0,0 +1,87 @@
|
||||
from openai import OpenAI
|
||||
|
||||
|
||||
def call_openai_style_model(base_url, api_key, model_name, prompt, **kwargs):
|
||||
client = OpenAI(
|
||||
base_url=base_url,
|
||||
api_key=api_key
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=model_name,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
**kwargs
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
|
||||
|
||||
def extract_json_substring(raw: str) -> str:
|
||||
"""从 LLM 的原始回答中提取最可能的 JSON 字符串片段。
|
||||
|
||||
处理思路:
|
||||
- 原始回答可能是:说明文字 + JSON + 说明文字,甚至带有 Markdown 代码块。
|
||||
- 优先在文本中查找第一个 '{' 或 '[' 作为 JSON 起始;
|
||||
- 再从后向前找最后一个 '}' 或 ']' 作为结束;
|
||||
- 如果找不到合适的边界,就退回原始字符串。
|
||||
- 部分模型可能会在回复中加入 `<think>...</think>` 内部思考内容,应在解析前先去除。
|
||||
- 也有模型会在 JSON 前后增加如 <reasoning>...</reasoning>、<analysis>...</analysis> 等标签,本方法会一并去除。
|
||||
该方法不会保证截取的一定是合法 JSON,但能显著提高 json.loads 的成功率。
|
||||
"""
|
||||
if not raw:
|
||||
return raw
|
||||
|
||||
try:
|
||||
import re
|
||||
|
||||
# 1. 先把所有完整的思考标签块整体去掉:<think>...</think> 等
|
||||
thought_tags = [
|
||||
"think",
|
||||
"thinking",
|
||||
"analysis",
|
||||
"reasoning",
|
||||
"reflection",
|
||||
"inner_thoughts",
|
||||
]
|
||||
for tag in thought_tags:
|
||||
pattern = rf"<{tag}>[\s\S]*?</{tag}>"
|
||||
raw = re.sub(pattern, "", raw, flags=re.IGNORECASE)
|
||||
|
||||
# 2. 再做一次“截取最后一个 </think>(或其它思考标签结束)之后的内容”的兜底
|
||||
# 这样就算标签不成对或嵌套异常,也能保留尾部真正的回答
|
||||
last_pos = -1
|
||||
for tag in thought_tags:
|
||||
# 匹配类似 </think> 或 </THINK>
|
||||
m = list(re.finditer(rf"</{tag}>", raw, flags=re.IGNORECASE))
|
||||
if m:
|
||||
last_pos = max(last_pos, m[-1].end())
|
||||
if last_pos != -1 and last_pos < len(raw):
|
||||
raw = raw[last_pos:]
|
||||
|
||||
except Exception:
|
||||
# 正则异常时不影响后续逻辑,继续使用当前文本
|
||||
pass
|
||||
|
||||
start = None
|
||||
end = None
|
||||
|
||||
# 查找第一个 JSON 起始符号
|
||||
for i, ch in enumerate(raw):
|
||||
if ch in "[{":
|
||||
start = i
|
||||
break
|
||||
|
||||
# 查找最后一个 JSON 结束符号
|
||||
for i in range(len(raw) - 1, -1, -1):
|
||||
if raw[i] in "]}":
|
||||
end = i + 1 # 切片是左闭右开
|
||||
break
|
||||
|
||||
if start is not None and end is not None and start < end:
|
||||
return raw[start:end].strip()
|
||||
|
||||
# 兜底:去掉常见 Markdown 包裹(```json ... ```)
|
||||
stripped = raw.strip()
|
||||
if stripped.startswith("```"):
|
||||
# 去掉首尾 ``` 标记
|
||||
stripped = stripped.strip("`")
|
||||
return stripped
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
资源感知并发计算工具
|
||||
|
||||
根据系统 CPU 和内存资源动态计算最优并发数
|
||||
适配全闪存储等高性能存储场景
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 尝试导入 psutil,失败则使用 os.cpu_count
|
||||
try:
|
||||
import psutil
|
||||
HAS_PSUTIL = True
|
||||
except ImportError:
|
||||
HAS_PSUTIL = False
|
||||
logger.warning("psutil not available, falling back to os.cpu_count() for CPU detection")
|
||||
|
||||
|
||||
def get_cpu_count(logical: bool = True) -> int:
|
||||
"""获取 CPU 核心数
|
||||
|
||||
Args:
|
||||
logical: 是否返回逻辑核心数(超线程),False返回物理核心数
|
||||
|
||||
Returns:
|
||||
CPU 核心数,无法检测时返回默认值 4
|
||||
"""
|
||||
if HAS_PSUTIL:
|
||||
count = psutil.cpu_count(logical=logical)
|
||||
if count:
|
||||
return count
|
||||
|
||||
# 回退到 os.cpu_count()
|
||||
count = os.cpu_count()
|
||||
return count if count else 4
|
||||
|
||||
|
||||
def get_available_memory_gb() -> float:
|
||||
"""获取可用内存(GB)
|
||||
|
||||
Returns:
|
||||
可用内存大小(GB),无法检测时返回默认值 8.0
|
||||
"""
|
||||
if HAS_PSUTIL:
|
||||
mem = psutil.virtual_memory()
|
||||
return mem.available / (1024 ** 3)
|
||||
|
||||
# 无法检测,返回保守默认值
|
||||
logger.warning("psutil not available, using default memory estimate")
|
||||
return 8.0
|
||||
|
||||
|
||||
def calculate_optimal_concurrent(
|
||||
min_concurrent: int = 8,
|
||||
max_concurrent: int = 128,
|
||||
cpu_factor: float = 4.0,
|
||||
memory_per_task_mb: int = 32,
|
||||
memory_reserve_ratio: float = 0.2,
|
||||
) -> int:
|
||||
"""根据系统资源计算最优并发数
|
||||
|
||||
计算逻辑:
|
||||
1. 基于 CPU 核心数:并发数 = CPU核心数 × cpu_factor
|
||||
2. 基于内存限制:可用内存 × (1-reserve_ratio) ÷ memory_per_task
|
||||
3. 最终取两者最小值,并限制在 [min_concurrent, max_concurrent] 范围内
|
||||
|
||||
Args:
|
||||
min_concurrent: 并发下限
|
||||
max_concurrent: 并发上限
|
||||
cpu_factor: CPU核心系数(全闪存储建议4.0)
|
||||
memory_per_task_mb: 每任务预估内存占用(MB)
|
||||
memory_reserve_ratio: 内存安全保留比例
|
||||
|
||||
Returns:
|
||||
计算得出的最优并发数
|
||||
"""
|
||||
# 获取 CPU 核心数
|
||||
cpu_count = get_cpu_count(logical=True)
|
||||
|
||||
# 基于 CPU 计算并发数
|
||||
cpu_based_concurrent = int(cpu_count * cpu_factor)
|
||||
|
||||
# 获取可用内存
|
||||
available_memory_gb = get_available_memory_gb()
|
||||
|
||||
# 计算可用于并发任务的内存(扣除保留部分)
|
||||
usable_memory_gb = available_memory_gb * (1 - memory_reserve_ratio)
|
||||
usable_memory_mb = usable_memory_gb * 1024
|
||||
|
||||
# 基于内存计算最大并发数
|
||||
memory_based_concurrent = int(usable_memory_mb / memory_per_task_mb)
|
||||
|
||||
# 取两者最小值(避免内存耗尽)
|
||||
calculated_concurrent = min(cpu_based_concurrent, memory_based_concurrent)
|
||||
|
||||
# 限制在范围内
|
||||
final_concurrent = max(min_concurrent, min(max_concurrent, calculated_concurrent))
|
||||
|
||||
# 记录计算过程
|
||||
logger.info(
|
||||
f"Concurrency calculation: "
|
||||
f"CPU cores={cpu_count}, CPU-based={cpu_based_concurrent}, "
|
||||
f"Available memory={available_memory_gb:.1f}GB, Memory-based={memory_based_concurrent}, "
|
||||
f"Final concurrent={final_concurrent} (range=[{min_concurrent}, {max_concurrent}])"
|
||||
)
|
||||
|
||||
return final_concurrent
|
||||
|
||||
|
||||
def get_concurrent_for_ratio_copy(settings) -> int:
|
||||
"""获取配比文件复制的并发数
|
||||
|
||||
Args:
|
||||
settings: 配置实例
|
||||
|
||||
Returns:
|
||||
并发数(动态计算或固定值)
|
||||
"""
|
||||
if not settings.ratio_copy_dynamic_concurrent:
|
||||
return settings.ratio_copy_fixed_concurrent
|
||||
|
||||
return calculate_optimal_concurrent(
|
||||
min_concurrent=settings.ratio_copy_min_concurrent,
|
||||
max_concurrent=settings.ratio_copy_max_concurrent,
|
||||
cpu_factor=settings.ratio_copy_cpu_factor,
|
||||
memory_per_task_mb=settings.ratio_copy_memory_per_task_mb,
|
||||
memory_reserve_ratio=settings.ratio_copy_memory_reserve_ratio,
|
||||
)
|
||||
|
||||
|
||||
def get_system_resource_info() -> dict:
|
||||
"""获取系统资源信息(用于日志和调试)
|
||||
|
||||
Returns:
|
||||
系统资源信息字典
|
||||
"""
|
||||
cpu_logical = get_cpu_count(logical=True)
|
||||
cpu_physical = get_cpu_count(logical=False) if HAS_PSUTIL else cpu_logical
|
||||
|
||||
if HAS_PSUTIL:
|
||||
mem = psutil.virtual_memory()
|
||||
return {
|
||||
"cpu_logical_cores": cpu_logical,
|
||||
"cpu_physical_cores": cpu_physical,
|
||||
"memory_total_gb": round(mem.total / (1024 ** 3), 2),
|
||||
"memory_available_gb": round(mem.available / (1024 ** 3), 2),
|
||||
"memory_used_percent": round(mem.percent, 1),
|
||||
"psutil_available": True,
|
||||
}
|
||||
|
||||
return {
|
||||
"cpu_logical_cores": cpu_logical,
|
||||
"cpu_physical_cores": cpu_physical,
|
||||
"memory_available_gb": get_available_memory_gb(),
|
||||
"psutil_available": False,
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import json
|
||||
|
||||
from enum import Enum
|
||||
from jsonschema import validate
|
||||
|
||||
class ItemTypes(Enum):
|
||||
QA = "QA"
|
||||
COT = "COT"
|
||||
|
||||
|
||||
class StructuredFileItemHandler:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def get_item_type(self) -> ItemTypes:
|
||||
pass
|
||||
|
||||
def validate_json(self, data):
|
||||
pass
|
||||
|
||||
def get_items_from_file(self, file_path: str) -> list[dict]:
|
||||
file_type = file_path.split(".")[-1].upper()
|
||||
items = []
|
||||
if file_type == "JSON":
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not self.validate_json(data):
|
||||
return items
|
||||
items = data
|
||||
elif file_type == "JSONL":
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
data = json.loads(line)
|
||||
if not self.validate_json(data):
|
||||
continue
|
||||
items.append(data)
|
||||
return items
|
||||
|
||||
class QAItemHandler(StructuredFileItemHandler):
|
||||
def __init__(self):
|
||||
self.schema_alpaca = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"instruction": {"type": "string"},
|
||||
"input": {"type": "string"},
|
||||
"output": {"type": "string"}
|
||||
},
|
||||
"required": ["instruction", "output"],
|
||||
}
|
||||
self.schema_alpaca_list = {
|
||||
"type": "array",
|
||||
"items": self.schema_alpaca,
|
||||
}
|
||||
super().__init__()
|
||||
|
||||
def get_item_type(self):
|
||||
return ItemTypes.QA
|
||||
|
||||
def validate_json(self, data):
|
||||
try:
|
||||
validate(instance=data, schema=self.schema_alpaca)
|
||||
return True
|
||||
except Exception as e:
|
||||
try:
|
||||
validate(instance=data, schema=self.schema_alpaca_list)
|
||||
return True
|
||||
except Exception as e:
|
||||
return False
|
||||
|
||||
|
||||
class COTItemHandler(StructuredFileItemHandler):
|
||||
def __init__(self):
|
||||
self.schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"instruction": {"type": "string"},
|
||||
"output": {"type": "string"},
|
||||
"chain_of_thought": {"type": "string"}
|
||||
},
|
||||
"required": ["question", "instruction", "output"],
|
||||
}
|
||||
self.schema_list = {
|
||||
"type": "array",
|
||||
"items": self.schema,
|
||||
}
|
||||
super().__init__()
|
||||
|
||||
def get_item_type(self):
|
||||
return ItemTypes.COT
|
||||
|
||||
def validate_json(self, data):
|
||||
try:
|
||||
validate(instance=data, schema=self.schema)
|
||||
return True
|
||||
except Exception as e:
|
||||
try:
|
||||
validate(instance=data, schema=self.schema_list)
|
||||
return True
|
||||
except Exception as e:
|
||||
return False
|
||||
|
||||
|
||||
class StructuredFileHandlerFactory:
|
||||
def __init__(self):
|
||||
self.handlers: list[StructuredFileItemHandler] = []
|
||||
self.handlers.append(QAItemHandler())
|
||||
self.handlers.append(COTItemHandler())
|
||||
|
||||
def get_handler(self, item_type: str) -> StructuredFileItemHandler:
|
||||
for handler in self.handlers:
|
||||
if handler.get_item_type().value == item_type:
|
||||
return handler
|
||||
raise ValueError(f"Unsupported item type: {item_type}")
|
||||
Reference in New Issue
Block a user