"""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