Python/agent/document/main.py

444 lines
18 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

# -*- coding: utf-8 -*-
"""
主模块
"""
from collections import deque
from hashlib import blake2s
from os import utime
from pathlib import Path
from re import MULTILINE, sub
from time import sleep, time
from frontmatter import load
from langchain_text_splitters import (
MarkdownHeaderTextSplitter,
RecursiveCharacterTextSplitter,
)
from paramiko import AutoAddPolicy, SSHClient
from psycopg import connect
from pydantic import BaseModel, Field
from requests import Response, exceptions, post
from transformers import AutoTokenizer
class Chunk(BaseModel):
"""
分块数据模型
"""
document_id: str = Field(..., description="文档唯一标识")
index: int = Field(..., description="分块索引")
text: str = Field(..., description="分块文本")
text_vector: list[float] | None = Field(default=None, description="分块文本向量")
text_fingerprint: str = Field(..., description="分块文本文本指纹")
text_tokens: int = Field(..., description="分块文本词元数")
return_text: str = Field(..., description="返回文本")
LOCAL_DIRECTORY = Path(__file__).parent / "vault" # 本地库文件夹
REMOTE_DIRECTORY = "/root/obsidian/vaults/MyNewVault/RawsSources" # 云端库文件夹
CHUNK_SIZE = 8192 # 分块大小,BAAI/BGE-M3标值
TPM = 500_000 * 0.8 # 每分钟词元数限额,硅基流动标值,安全冗余20%
WINDOW_PERIOD = 60 # 窗口期
def sync_vault() -> None:
"""
同步本地库和云端库
"""
print("===== 开始同步本地库和云端库 =====")
# 创建 SSHClient
ssh = SSHClient()
ssh.set_missing_host_key_policy(AutoAddPolicy())
ssh.connect(
hostname="www.liubiren.cloud",
port=22,
username="root",
password="Te198752.",
)
# 创建 SFTP
sftp = ssh.open_sftp()
def get_remote_vault_metadata(folder: str) -> list[dict]:
"""
获取云端库指定文件夹下所有元数据(文档即MD文件,每个文档包含云端路径和修改时间戳)
:param folder: 文件夹
:return: [ {"remote_path":str, "modified_at":float}, ... ]
"""
metadata = []
# 获取指定文件夹下所有实体列表
entries = sftp.listdir_attr(folder)
for entry in entries:
# 若实体模式掩码为空则跳过
if (entry_mode_mask := entry.st_mode) is None:
continue
# 构建云端路径
remote_path = f"{folder}/{entry.filename}"
# 若实体为文件夹则递归获取该文件夹下所有元数据
if entry_mode_mask & 0o040000:
metadata.extend(get_remote_vault_metadata(remote_path))
# 若实体文件为MD文件则构建文档源数据并添加到列表
elif entry.filename.lower().endswith(".md"):
metadata.append(
{"remote_path": remote_path, "modified_at": entry.st_mtime or 0}
)
return metadata
# 获取云端库指定文件夹下所有元数据
remote_vault_metadata = get_remote_vault_metadata(REMOTE_DIRECTORY)
print(f"📁 云端库扫描完成,文档数:{len(remote_vault_metadata)}")
download_count = 0
for metadata in remote_vault_metadata:
# 本地路径
local_path: Path = LOCAL_DIRECTORY / Path(metadata["remote_path"]).relative_to(
REMOTE_DIRECTORY
)
local_path.parent.mkdir(parents=True, exist_ok=True)
is_download_needed = True # 需要下载
modified_at = metadata["modified_at"]
# 若本地文档存在且修改时间戳与云端文档修改时间戳差异小于阈值则无需下载
if local_path.exists():
if abs(local_path.stat().st_mtime - modified_at) < 60:
is_download_needed = False
if is_download_needed:
print(f"⬇️ 下载文档:{metadata['remote_path']}")
# 下载文档
sftp.get(metadata["remote_path"], local_path.as_posix())
# 同步修改时间戳
utime(local_path, times=(modified_at, modified_at))
download_count += 1
print(f"✅ 同步完成,下载文档数:{download_count}")
sftp.close()
ssh.close()
print("===== 本地库和云端库同步完成 =====\n")
def process_documents() -> None:
"""
处理文档
"""
print("===== 开始文档处理 =====")
# 实例化标题分割器
header_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=[
("#", "h1"),
("##", "h2"),
("###", "h3"),
("####", "h4"),
("#####", "h5"),
("######", "h6"),
],
strip_headers=True, # 不保留标题
)
# 实例化分词器(已保存预训练模型,本地加载)
tokenizer = AutoTokenizer.from_pretrained(
Path(__file__).parent / "baai_bge_m3_tokenizer"
)
def count_tokens(text: str) -> int:
"""
统计词元数
:param text: 文本
:return: token 数量
"""
return len(
tokenizer.encode(text, add_special_tokens=False)
) # 首尾不添加模型专用特殊标记
# 实例化窗口队列(用于保存窗口期内请求)
window_deque: deque[tuple[float, int]] = deque()
def get_window_deque_text_tokens() -> int:
"""
获取窗口队列的文本词元数
:return: 文本词元数
"""
# 当前时间戳
current_time = time()
# 清理过期请求
while window_deque and window_deque[0][0] < current_time - WINDOW_PERIOD:
window_deque.popleft()
return sum(text_tokens for _, text_tokens in window_deque)
with connect(
host="www.liubiren.cloud",
port=5201,
user="root",
password="PG198752",
dbname="database",
) as connection:
print("📖 加载文档同步记录...")
document_sync_records: dict[str, float] = {} # 文档同步记录字典
with connection.cursor() as cursor:
cursor.execute("SELECT id, modified_at FROM document_sync_records;")
for id, modified_at in cursor.fetchall():
document_sync_records[id] = modified_at
print(f"✅ 加载完成,数据库中文档数:{len(document_sync_records)}")
for idx, local_path in enumerate(
local_paths := list(LOCAL_DIRECTORY.rglob("*.md")), 1
):
# 文档标题
document_title = local_path.stem
print(f"📄 [{idx}/{len(local_paths)}] 正在处理文档:{document_title}\n")
# 读取文档
document = load(local_path)
# 解析文档唯一标识(需Obsidian已安装NoteUIDGenerator插件,用于生成唯一标识)
document_id = str(document.metadata["uid"])
# 本地文档修改时间戳
modified_at = local_path.stat().st_mtime
operation_type = "create" # 操作类型
if document_id in document_sync_records:
# 若本地文档修改时间戳与数据库文档修改时间戳差异小于阈值则跳过
if abs(modified_at - document_sync_records[document_id]) < 60:
print(f"⏭️ 该文档未变动,无需处理")
continue
operation_type = "update"
print(f"🔄 该文档需要更新")
else:
print(f"🔄 该文档需要创建")
index = 0
current_batch: list[Chunk] = [] # 当前批次列表
current_batch_text_tokens: int = 0 # 当前批次列表的文本词元数
batches: list[list[Chunk]] = [] # 批次列表
# 先根据标题分割文档,若分块词元大小超过阈值则使用递归字符分割器进一步分割
for chunk in header_splitter.split_text(document.content):
# --- 构建分块标题 ---
headers = []
# 若h1标题为空则使用文档标题
if not (h1 := chunk.metadata.get("h1")):
headers.append(document_title.strip())
else:
headers.append(h1.strip())
if h2 := chunk.metadata.get("h2"):
headers.append(h2.strip())
if h3 := chunk.metadata.get("h3"):
headers.append(h3.strip())
if h4 := chunk.metadata.get("h4"):
headers.append(h4.strip())
if h5 := chunk.metadata.get("h5"):
headers.append(h5.strip())
if h6 := chunk.metadata.get("h6"):
headers.append(h6.strip())
header = f"标题层级:{" > ".join(headers)}\n\n"
# --- 构建分块内容 ---
# 若分块内容为空则跳过
if not (content := chunk.page_content.strip()):
continue
# 删除行尾空格
content = sub(r"[ \t]+$", "", content, flags=MULTILINE)
# 将全角空格替换为半角空格
content = content.replace(" ", " ")
# 压缩空格
content = sub(r" +", " ", content)
# 压缩换行符
content = sub(r"\n{3,}", "\n\n", content)
# 删除分割线标记
content = content.replace("---", "")
# 返回文本
return_text = header + content
# 删除斜体标记
content = sub(r"\*(.*?)\*", r"\1", content)
# 删除加粗标记
content = sub(r"\*\*(.*?)\*\*", r"\1", content)
# 删除代码块标记
content = sub(r"`(.*?)`", r"\1", content)
# 删除文档链接标记
content = sub(r"\[\[(.*?)\]\]", r"\1", content)
# 删除图片标记(直接删除)
content = sub(r"!\[.*?\]\(.*?\)", "", content)
# 删除普通连接标记(删除URL,保留文本)
content = sub(r"\[(.*?)\]\(.*?\)", r"\1", content)
# 若文本词元数未超过限制则以分块内容作为分块内容列表
if count_tokens(header + content) < CHUNK_SIZE:
contents = [content]
# 若文本词元数超过限制则使用递归字符分割器进一步分割分块内容
else:
# 实例化递归字符分割器
recursive_splitter = RecursiveCharacterTextSplitter(
chunk_size=CHUNK_SIZE - count_tokens(header),
chunk_overlap=60, # 分块重叠
length_function=count_tokens,
separators=["\n\n", "\n", "。", "?", "!", ".", "?", "!", ""],
)
contents = recursive_splitter.split_text(content)
for content in contents:
# 若内容为空则跳过
if not content.strip():
continue
chunk = Chunk(
document_id=document_id,
index=index,
text=(text := header + content),
text_fingerprint=blake2s(
text.encode(), digest_size=16
).hexdigest(),
text_tokens=count_tokens(text),
return_text=return_text,
)
# 若当前批次列表的文本词元数加上当前分块的文本词元数超过阈值则将当前批次列表添加至批次列表
if current_batch_text_tokens + chunk.text_tokens > TPM:
batches.append(current_batch)
current_batch = [chunk]
current_batch_text_tokens = chunk.text_tokens
else:
current_batch.append(chunk)
current_batch_text_tokens += chunk.text_tokens
index += 1
# 若当前批次列表不为空则将当前批次列表添加至批次列表
if current_batch:
batches.append(current_batch)
print(f"📝 该文档切块完成,分块数:{index}")
# 按批次请求计算文本向量,若某批次请求发生异常则跳过该文档
try:
with connection.cursor() as cursor:
# 更新场景:先删除当前文档全部旧chunk
if operation_type == "update":
cursor.execute(
"DELETE FROM document_chunks WHERE document_id = %s;",
(document_id,),
)
print(f"🗑️ 删除原有分块")
for i, batch in enumerate(batches, 1):
# 统计批次的文本词元数
batch_text_tokens = sum(chunk.text_tokens for chunk in batch)
print(f" > 📦 Batch[{i}/{len(batches)}] 正在处理批次分块")
# 若窗口队列的文本词元数加上批次的文本词元数超过阈值则等待窗口队列的最早请求时间加上窗口期
while True:
# 获取窗口队列的文本词元数
window_deque_text_tokens = get_window_deque_text_tokens()
if window_deque_text_tokens + batch_text_tokens < TPM:
break
sleep(
max((window_deque[0][0] + WINDOW_PERIOD) - time(), 0.2)
)
request_counts = 0 # 请求次数
response_payload: dict | None = None
while request_counts < 3:
request_counts += 1
response: Response | None = None
try:
response = post(
"https://api.siliconflow.cn/v1/embeddings",
headers={
"Authorization": "Bearer sk-xsnuwirjjphhfdbvznfdfjqlinfdlrnlxuhkbbqynfnbhiqz",
"Content-Type": "application/json",
},
json={
"model": "BAAI/bge-m3",
"input": [chunk.text for chunk in batch],
},
timeout=60,
)
response.raise_for_status()
response_payload = response.json()
# 请求成功则将本次请求添加至窗口队列
window_deque.append((time(), batch_text_tokens))
break
except exceptions.HTTPError as exception:
# 触发限速
if response is not None and response.status_code == 429:
sleep(2**request_counts)
continue
raise RuntimeError(
f"HTTPError: {exception}, resp={response.text if response is not None else 'response is None'}"
)
except Exception as exception:
print(f"Exception: {exception}")
sleep(2**request_counts)
if response_payload is None:
raise RuntimeError("response_payload is None")
# 写入文本向量
for chunk, text_vector in zip(
batch,
[item["embedding"] for item in response_payload["data"]],
):
chunk.text_vector = text_vector
# 批量创建文档分块记录
cursor.executemany(
"""
INSERT INTO document_chunks
(document_id, index, text, text_vector, text_fingerprint, text_tokens, return_text)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
[
(
chunk.document_id,
chunk.index,
chunk.text,
chunk.text_vector,
chunk.text_fingerprint,
chunk.text_tokens,
chunk.return_text,
)
for chunk in batch
],
)
print(f" ✅ 该批次分块处理完成")
# 创建文档同步记录
cursor.execute(
"""
INSERT INTO document_sync_records (id, modified_at)
VALUES (%s, %s)
ON CONFLICT (id) DO UPDATE
SET modified_at = EXCLUDED.modified_at;
""",
(document_id, modified_at),
)
connection.commit()
# 更新内存缓存
document_sync_records[document_id] = modified_at
print(f"✅ 该文档处理完成\n")
except Exception:
connection.rollback()
continue
print("===== 全部文档处理完成 =====")
def main():
# 同步本地库和云端库
sync_vault()
# 处理文档
process_documents()
main()