feat(server): add workbench API, DSH planner worker and RAG pipeline
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
"""知识库导入去重:只比较精确正文,不以语义相似度删除资料。"""
|
||||
from contextlib import contextmanager
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
from ..config import DATA_DIR
|
||||
|
||||
DEDUP_DIR = DATA_DIR / "rag_dedup"
|
||||
_locks: dict[int, threading.RLock] = {}
|
||||
_locks_guard = threading.Lock()
|
||||
|
||||
|
||||
def file_hash(path: Path) -> str:
|
||||
"""流式原始文件指纹;大文件不会为计算哈希额外复制到内存。"""
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def normalized_text(value: str) -> str:
|
||||
# 保留字词、数字、标点及它们的顺序,仅统一排版空白。
|
||||
return " ".join(str(value or "").split())
|
||||
|
||||
|
||||
def document_hash(units: list[dict]) -> str:
|
||||
digest = hashlib.sha256()
|
||||
for unit in units:
|
||||
text = normalized_text(unit.get("text", ""))
|
||||
if not text:
|
||||
continue
|
||||
# 标题是正文语义的一部分;页码/字幕编号不是。
|
||||
heading = normalized_text((unit.get("meta") or {}).get("heading", ""))
|
||||
for value in (heading, text):
|
||||
encoded = value.encode("utf-8")
|
||||
digest.update(len(encoded).to_bytes(8, "big"))
|
||||
digest.update(encoded)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def unique_chunks(chunks: list[dict]) -> tuple[list[dict], int]:
|
||||
"""同一文件精确重复段只嵌入一次,保留首条来源与原始顺序。"""
|
||||
unique, seen = [], {}
|
||||
for ordinal, item in enumerate(chunks):
|
||||
key = (normalized_text((item.get("meta") or {}).get("heading", "")),
|
||||
normalized_text(item.get("text", "")))
|
||||
if key in seen:
|
||||
seen[key]["meta"]["duplicate_count"] += 1
|
||||
continue
|
||||
retained = {**item, "meta": dict(item.get("meta") or {})}
|
||||
retained["meta"].update(seq=ordinal, source_seq=ordinal, duplicate_count=0)
|
||||
seen[key] = retained
|
||||
unique.append(retained)
|
||||
return unique, len(chunks) - len(unique)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def library_lock(kb_id: int):
|
||||
"""线程与进程都串行写同一库,避免同时上传相同文件穿过去重。"""
|
||||
with _locks_guard:
|
||||
lock = _locks.setdefault(int(kb_id), threading.RLock())
|
||||
with lock:
|
||||
DEDUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with (DEDUP_DIR / f"kb_{int(kb_id)}.lock").open("a") as handle:
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # Windows 保留线程保护;生产为 macOS/Linux。
|
||||
fcntl = None
|
||||
if fcntl:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if fcntl:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
class FingerprintCache:
|
||||
"""仅缓存解析指纹;当前原文件 SHA256 和现有索引仍独立校验。"""
|
||||
def __init__(self, kb_id: int):
|
||||
self.path = DEDUP_DIR / f"kb_{int(kb_id)}.json"
|
||||
try:
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
self.values = data.get("values", {}) if data.get("version") == 1 else {}
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
self.values = {}
|
||||
|
||||
def get(self, raw_hash: str) -> str:
|
||||
value = self.values.get(raw_hash)
|
||||
return value if isinstance(value, str) and len(value) == 64 else ""
|
||||
|
||||
def set(self, raw_hash: str, body_hash: str):
|
||||
self.values[raw_hash] = body_hash
|
||||
|
||||
def save(self):
|
||||
temp = self.path.with_suffix(f".{uuid.uuid4().hex}.tmp")
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp.write_text(json.dumps({"version": 1, "values": self.values}), encoding="utf-8")
|
||||
os.replace(temp, self.path)
|
||||
except OSError:
|
||||
# 只是加速缓存,不能让已成功的入库变为失败。
|
||||
pass
|
||||
finally:
|
||||
temp.unlink(missing_ok=True)
|
||||
Reference in New Issue
Block a user