feat(server): add workbench API, DSH planner worker and RAG pipeline

This commit is contained in:
yi
2026-09-16 19:06:20 +08:00
parent 72fc5b47c7
commit d4b5b3f43a
122 changed files with 31906 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
"""Persistent, topic-neutral reading records scoped to an organization and library.
Only exact current input bytes are reused. File manifests are an optional, conservative
estimate: missing index revision metadata always means zero estimated reuse.
"""
from __future__ import annotations
import hashlib
import json
import os
import sqlite3
import time
import uuid
from contextlib import contextmanager
from pathlib import Path
from sqlmodel import select
from .config import DATA_DIR
from .models import KnowledgeBase, KnowledgeFile
KNOWLEDGE_DIR = DATA_DIR / "training-knowledge"
GENERIC_TARGET = 3600
CACHE_POLICY = "training-knowledge-ordered-v2"
LOCK_WAIT_SECONDS = 600
class ReadingCancelled(RuntimeError):
pass
def check_stop(should_stop):
if should_stop and should_stop():
raise ReadingCancelled("资料阅读已取消")
def canonical_entries(entries):
"""Exclude snapshot E IDs, library display names, topic and selection metadata."""
return [{"kb_id": item["kb_id"], "file": item["file"], "chunk_id": item["chunk_id"],
"text": item["text"], "kind": "raw", "source_count": 1,
"source_seq": item.get("source_seq"), "heading": item.get("heading", "")} for item in entries]
def _directory(organization_id, kb_id):
for value in (organization_id, kb_id):
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise ValueError("知识阅读记录的组织或知识库编号无效")
root = KNOWLEDGE_DIR.resolve()
directory = KNOWLEDGE_DIR / str(organization_id) / str(kb_id)
if directory.resolve() != root / str(organization_id) / str(kb_id):
raise ValueError("知识阅读记录路径无效")
return directory
def fingerprint(entries, max_chars, signature):
payload = {"policy": CACHE_POLICY, "signature": signature, "max_chars": max_chars,
"entries": canonical_entries(entries)}
return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True,
separators=(",", ":")).encode("utf-8")).hexdigest()
def _cache_path(directory, key):
return directory / key[:2] / f"{key}.json"
def _saved(path, key, max_chars, min_quality="any"):
try:
data = json.loads(path.read_text(encoding="utf-8"))
summary = data.get("text")
quality = str(data.get("quality") or "full")
quality_ok = min_quality != "full" or quality == "full"
if (quality_ok and data.get("fingerprint") == key and isinstance(summary, str)
and summary.strip() and len(summary) <= max_chars):
return summary
except (OSError, ValueError, TypeError, AttributeError):
pass
return None
@contextmanager
def _singleflight(path, should_stop):
# POSIX flock serializes separate worker processes. Windows keeps the
# in-process call path working via threading; local production is Linux.
try:
import fcntl
except ImportError:
fcntl = None
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a") as handle:
try:
os.chmod(path, 0o600)
except OSError:
pass
deadline = time.monotonic() + LOCK_WAIT_SECONDS
while True:
check_stop(should_stop)
try:
if fcntl is not None:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError:
if time.monotonic() >= deadline:
raise TimeoutError("等待同批资料阅读超时,请稍后重试")
time.sleep(0.1)
try:
yield
finally:
if fcntl is not None:
fcntl.flock(handle, fcntl.LOCK_UN)
def reusable_summary(organization_id, kb_id, entries, max_chars, signature, generate,
should_stop=None, *, quality="full", min_quality="any"):
"""Return (validated text, reused, fingerprint). Failed/cancelled calls never save."""
check_stop(should_stop)
key = fingerprint(entries, max_chars, signature)
if not signature:
summary = generate()
check_stop(should_stop)
_validate(summary, max_chars)
return summary.strip(), False, ""
directory = _directory(organization_id, kb_id)
path = _cache_path(directory, key)
path.parent.mkdir(parents=True, exist_ok=True)
with _singleflight(path.with_suffix(".lock"), should_stop):
check_stop(should_stop)
summary = _saved(path, key, max_chars, min_quality=min_quality)
if summary is not None:
return summary, True, key
summary = generate()
check_stop(should_stop)
_validate(summary, max_chars)
summary = summary.strip()
staging = path.with_name(f"{key}.{uuid.uuid4().hex}.tmp")
try:
with staging.open("x", encoding="utf-8") as handle:
os.chmod(staging, 0o600)
json.dump({"fingerprint": key, "text": summary, "quality": quality}, handle, ensure_ascii=False)
check_stop(should_stop)
os.replace(staging, path)
finally:
staging.unlink(missing_ok=True)
return summary, False, key
def _validate(summary, max_chars):
if not isinstance(summary, str) or not summary.strip():
raise ValueError("分批阅读未返回摘要")
if len(summary) > max_chars:
raise ValueError(f"分批摘要超出 {max_chars} 字符预算,已停止避免截断资料")
def index_revision(kb_id):
"""Stat only; never open/create Chroma or scan indexed text for an estimate."""
from .rag import store
from .rag import backend
if backend.is_qdrant():
return "qdrant:" + store.kb_revision(kb_id)
path = store._fallback_path(kb_id)
try:
stat = path.stat()
return f"fallback:{stat.st_dev}:{stat.st_ino}:{stat.st_size}:{stat.st_mtime_ns}:{stat.st_ctime_ns}"
except OSError:
# Shared DB/WAL metadata is a conservative global token: any index write
# invalidates estimates for all libraries. Actual reuse still hashes text.
from .config import CHROMA_DIR
paths = [CHROMA_DIR / 'chroma.sqlite3', CHROMA_DIR / 'chroma.sqlite3-wal']
versions = []
for i, candidate in enumerate(paths):
try:
stat = candidate.stat()
versions.append([stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns])
except FileNotFoundError:
if i == 0:
return ''
versions.append(None)
except OSError:
return ''
return 'chroma:' + hashlib.sha256(json.dumps(versions).encode()).hexdigest()
def record_manifest(organization_id, kb_id, file_name, content_hash, revision, chunk_count,
signature, index_token, batch_keys):
signature = f"{CACHE_POLICY}:{signature}" if signature else ""
if not content_hash or not signature or not index_token or not chunk_count or not batch_keys or not all(batch_keys):
return
directory = _directory(organization_id, kb_id)
directory.mkdir(parents=True, exist_ok=True)
path = directory / "manifest.sqlite"
with sqlite3.connect(path, timeout=30) as cache:
os.chmod(path, 0o600)
cache.execute("""CREATE TABLE IF NOT EXISTS files (
file_name TEXT NOT NULL, signature TEXT NOT NULL, content_hash TEXT NOT NULL,
revision INTEGER NOT NULL, chunk_count INTEGER NOT NULL, index_token TEXT NOT NULL,
batch_keys TEXT NOT NULL, PRIMARY KEY(file_name, signature))""")
cache.execute("INSERT OR REPLACE INTO files VALUES(?,?,?,?,?,?,?)",
(file_name, signature, content_hash, revision, chunk_count, index_token, json.dumps(batch_keys)))
def reuse_inventory(session, kb_ids, mapping, signature):
"""Read-only estimate; requires the same source version and unchanged index file.
Caller verifies authorization before entering this helper. Scope is checked again
and only each current library's own organization directory is considered. A KB
index rewrite conservatively reports zero until a successful scan refreshes its
manifests, even when unchanged files will in fact reuse their exact summaries.
"""
result = {"reusable_chunks": 0, "reusable_files": 0, "reusable_batches": 0}
if not signature:
return result
signature = f"{CACHE_POLICY}:{signature}"
from .training_scope import normalize_scope, file_scope
normalized = normalize_scope(session, kb_ids, mapping or {})
for kid in dict.fromkeys(kb_ids):
kb = session.get(KnowledgeBase, kid)
if not kb or kb.index_status != "ready" or not kb.organization_id:
continue
token = index_revision(kid)
if not token:
continue
allowed = file_scope(session, kid, normalized)
directory = _directory(kb.organization_id, kid)
path = directory / "manifest.sqlite"
if not path.is_file():
continue
try:
with sqlite3.connect(f"file:{path}?mode=ro", uri=True) as cache:
manifests = {row[0]: row[1:] for row in cache.execute(
"SELECT file_name,content_hash,revision,chunk_count,index_token,batch_keys FROM files WHERE signature=?",
(signature,))}
except (OSError, sqlite3.Error):
continue
files = session.exec(select(KnowledgeFile).where(KnowledgeFile.kb_id == kid)).all()
seen = set()
for file in files:
if file.file_name in seen or (allowed is not None and file.file_name not in allowed) or not file.content_hash:
continue
seen.add(file.file_name)
saved = manifests.get(file.file_name)
if not saved or saved[0] != file.content_hash or saved[1] != file.revision or saved[3] != token:
continue
try:
keys = json.loads(saved[4])
if not isinstance(keys, list) or not keys or not all(
isinstance(key, str) and len(key) == 64 and all(c in "0123456789abcdef" for c in key)
and _cache_path(directory, key).is_file() for key in keys
):
continue
count = int(saved[2])
if count <= 0:
continue
except (ValueError, TypeError, OSError):
continue
result["reusable_chunks"] += count
result["reusable_files"] += 1
result["reusable_batches"] += len(keys)
return result