ForcePilot/src/knowledge/utils/kb_utils.py
2025-11-14 00:33:39 +08:00

278 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import hashlib
import os
import time
from pathlib import Path
from langchain_text_splitters import MarkdownTextSplitter
from src import config
from src.utils import hashstr, logger
from src.utils.datetime_utils import utc_isoformat
def validate_file_path(file_path: str, db_id: str = None) -> str:
"""
验证文件路径安全性,防止路径遍历攻击
Args:
file_path: 要验证的文件路径
db_id: 数据库ID用于获取知识库特定的上传目录
Returns:
str: 规范化后的安全路径
Raises:
ValueError: 如果路径不安全
"""
try:
# 规范化路径
normalized_path = os.path.abspath(os.path.realpath(file_path))
# 获取允许的根目录
from src.knowledge import knowledge_base
allowed_dirs = [
os.path.abspath(os.path.realpath(config.save_dir)),
]
# 如果指定了db_id添加知识库特定的上传目录
if db_id:
try:
allowed_dirs.append(os.path.abspath(os.path.realpath(knowledge_base.get_db_upload_path(db_id))))
except Exception:
# 如果无法获取db路径使用通用上传目录
allowed_dirs.append(
os.path.abspath(os.path.realpath(os.path.join(config.save_dir, "database", "uploads")))
)
# 检查路径是否在允许的目录内
is_safe = False
for allowed_dir in allowed_dirs:
try:
if normalized_path.startswith(allowed_dir):
is_safe = True
break
except Exception:
continue
if not is_safe:
logger.warning(f"Path traversal attempt detected: {file_path} (normalized: {normalized_path})")
raise ValueError(f"Access denied: Invalid file path: {file_path}")
return normalized_path
except Exception as e:
logger.error(f"Path validation failed for {file_path}: {e}")
raise ValueError(f"Invalid file path: {file_path}")
def split_text_into_chunks(text: str, file_id: str, filename: str, params: dict = {}) -> list[dict]:
"""
将文本分割成块,使用 LangChain 的 MarkdownTextSplitter 进行智能分割
"""
chunks = []
chunk_size = params.get("chunk_size", 1000)
chunk_overlap = params.get("chunk_overlap", 200)
# 使用 MarkdownTextSplitter 进行智能分割
# MarkdownTextSplitter 会尝试沿着 Markdown 格式的标题进行分割
text_splitter = MarkdownTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
)
text_chunks = text_splitter.split_text(text)
# 转换为标准格式
for chunk_index, chunk_content in enumerate(text_chunks):
if chunk_content.strip(): # 跳过空块
chunks.append(
{
"id": f"{file_id}_chunk_{chunk_index}",
"content": chunk_content.strip(),
"file_id": file_id,
"filename": filename,
"chunk_index": chunk_index,
"source": filename,
"chunk_id": f"{file_id}_chunk_{chunk_index}",
}
)
logger.debug(f"Successfully split text into {len(chunks)} chunks using MarkdownTextSplitter")
return chunks
def calculate_content_hash(data: bytes | bytearray | str | os.PathLike[str] | Path) -> str:
"""
计算文件内容的 SHA-256 哈希值。
Args:
data: 文件内容的二进制数据或文件路径
Returns:
str: 十六进制哈希值
"""
sha256 = hashlib.sha256()
if isinstance(data, (bytes, bytearray)):
sha256.update(data)
return sha256.hexdigest()
if isinstance(data, (str, os.PathLike, Path)):
path = Path(data)
with path.open("rb") as file_handle:
for chunk in iter(lambda: file_handle.read(8192), b""):
sha256.update(chunk)
return sha256.hexdigest()
raise TypeError(f"Unsupported data type for hashing: {type(data)!r}")
def prepare_item_metadata(item: str, content_type: str, db_id: str, params: dict | None = None) -> dict:
"""
准备文件或URL的元数据
Args:
item: 文件路径或URL
content_type: 内容类型 ("file""url")
db_id: 数据库ID
params: 处理参数,可选
"""
if content_type == "file":
file_path = Path(item)
file_id = f"file_{hashstr(str(file_path) + str(time.time()), 6)}"
file_type = file_path.suffix.lower().replace(".", "")
filename = file_path.name
item_path = os.path.relpath(file_path, Path.cwd())
content_hash = None
try:
if file_path.exists():
content_hash = calculate_content_hash(file_path)
except Exception as exc: # noqa: BLE001
logger.warning(f"Failed to calculate content hash for {file_path}: {exc}")
else: # URL
file_id = f"url_{hashstr(item + str(time.time()), 6)}"
file_type = "url"
filename = f"webpage_{hashstr(item, 6)}.md"
item_path = item
content_hash = None
metadata = {
"database_id": db_id,
"filename": filename,
"path": item_path,
"file_type": file_type,
"status": "processing",
"created_at": utc_isoformat(),
"file_id": file_id,
"content_hash": content_hash,
}
# 保存处理参数到元数据
if params:
metadata["processing_params"] = params.copy()
return metadata
def split_text_into_qa_chunks(
text: str, file_id: str, filename: str, qa_separator: None | str = None, params: dict = {}
) -> list[dict]:
"""
将文本按QA对分割成块使用 LangChain 的 CharacterTextSplitter 进行分割"""
qa_separator = qa_separator or "\n\n"
text_chunks = text.split(qa_separator)
# 转换为标准格式
chunks = []
for chunk_index, chunk_content in enumerate(text_chunks):
if chunk_content.strip(): # 跳过空块
chunk_content = chunk_content.strip()[:4096]
chunks.append(
{
"id": f"{file_id}_qa_chunk_{chunk_index}",
"content": chunk_content.strip(),
"file_id": file_id,
"filename": filename,
"chunk_index": chunk_index,
"source": filename,
"chunk_id": f"{file_id}_qa_chunk_{chunk_index}",
"chunk_type": "qa", # 标识为QA类型的chunk
}
)
logger.debug(f"QA chunks: {chunks[0]}")
logger.debug(
f"Successfully split QA text into {len(chunks)} chunks using CharacterTextSplitter with `{qa_separator=}`"
)
return chunks
def merge_processing_params(metadata_params: dict | None, request_params: dict | None) -> dict:
"""
合并处理参数:优先使用请求参数,缺失时使用元数据中的参数
Args:
metadata_params: 元数据中保存的参数
request_params: 请求中提供的参数
Returns:
dict: 合并后的参数
"""
merged_params = {}
# 首先使用元数据中的参数作为默认值
if metadata_params:
merged_params.update(metadata_params)
# 然后使用请求参数覆盖(如果提供)
if request_params:
merged_params.update(request_params)
logger.debug(f"Merged processing params: metadata={metadata_params}, request={request_params}, result={merged_params}")
return merged_params
def get_embedding_config(embed_info: dict) -> dict:
"""
获取嵌入模型配置
Args:
embed_info: 嵌入信息字典
Returns:
dict: 标准化的嵌入配置
"""
config_dict = {}
try:
if embed_info:
# 处理 embed_info 可能是字典或 EmbedModelInfo 对象的情况
if hasattr(embed_info, "name"):
# EmbedModelInfo 对象
config_dict["model"] = embed_info.name
config_dict["api_key"] = os.getenv(embed_info.api_key) or embed_info.api_key
config_dict["base_url"] = embed_info.base_url
config_dict["dimension"] = embed_info.dimension
else:
# 字典形式
config_dict["model"] = embed_info["name"]
config_dict["api_key"] = os.getenv(embed_info["api_key"]) or embed_info["api_key"]
config_dict["base_url"] = embed_info["base_url"]
config_dict["dimension"] = embed_info.get("dimension", 1024)
else:
from src.models import select_embedding_model
default_model = select_embedding_model(config.embed_model)
config_dict["model"] = default_model.model
config_dict["api_key"] = default_model.api_key
config_dict["base_url"] = default_model.base_url
config_dict["dimension"] = getattr(default_model, "dimension", 1024)
except Exception as e:
logger.error(f"Error in get_embedding_config: {e}, {embed_info}")
raise ValueError(f"Error in get_embedding_config: {e}")
logger.debug(f"Embedding config: {config_dict}")
return config_dict