themis / phase1 /scripts /project_store.py
vg15o2's picture
Moonley backend (HF Space build)
1d9bd9b
Raw
History Blame Contribute Delete
17.9 kB
"""Persistent, Clerk-owned project and knowledge-document storage.
The store deliberately requires an explicit root directory. On Hugging Face this
must point at a read/write Storage Bucket mounted into the Space; silently using
the Space's ephemeral filesystem would make legal files disappear on restart.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import threading
import time
import uuid
import zipfile
from dataclasses import dataclass
from pathlib import Path
MIB = 1024 * 1024
ALLOWED_EXTENSIONS = {".pdf", ".docx", ".txt", ".md"}
MEDIA_TYPES = {
".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".txt": "text/plain",
".md": "text/markdown",
}
class ProjectStoreError(Exception):
status_code = 400
code = "project_store_error"
def __init__(self, message: str):
super().__init__(message)
self.message = message
class StorageUnavailable(ProjectStoreError):
status_code = 503
code = "project_storage_unavailable"
class NotFound(ProjectStoreError):
status_code = 404
code = "not_found"
class QuotaExceeded(ProjectStoreError):
status_code = 413
code = "knowledge_quota_exceeded"
class UnsupportedDocument(ProjectStoreError):
status_code = 415
code = "unsupported_document"
@dataclass(frozen=True)
class ProjectLimits:
max_projects: int = 20
max_documents: int = 25
max_file_bytes: int = 10 * MIB
max_project_bytes: int = 50 * MIB
max_user_bytes: int = 250 * MIB
@classmethod
def from_env(cls) -> "ProjectLimits":
def number(suffix: str, default: int) -> int:
raw = os.environ.get(
f"MOONLEY_PROJECT_{suffix}",
os.environ.get(f"THEMIS_PROJECT_{suffix}", ""),
).strip()
try:
value = int(raw) if raw else default
except ValueError:
value = default
return max(1, value)
return cls(
max_projects=number("MAX_PROJECTS", 20),
max_documents=number("MAX_DOCUMENTS", 25),
max_file_bytes=number("MAX_FILE_BYTES", 10 * MIB),
max_project_bytes=number("MAX_BYTES", 50 * MIB),
max_user_bytes=number("MAX_USER_BYTES", 250 * MIB),
)
def public_dict(self) -> dict:
return {
"max_projects": self.max_projects,
"max_documents_per_project": self.max_documents,
"max_file_bytes": self.max_file_bytes,
"max_project_bytes": self.max_project_bytes,
"max_user_bytes": self.max_user_bytes,
"allowed_extensions": sorted(ALLOWED_EXTENSIONS),
}
def _now() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def _clean_name(name: str, *, limit: int = 80) -> str:
value = re.sub(r"\s+", " ", str(name or "")).strip()
if not value:
raise ProjectStoreError("A project name is required.")
if len(value) > limit:
raise ProjectStoreError(f"Project names must be {limit} characters or fewer.")
return value
def _clean_filename(filename: str) -> str:
value = Path(str(filename or "").replace("\\", "/")).name.strip()
value = re.sub(r"[\x00-\x1f\x7f]", "", value)
if not value or value in {".", ".."}:
raise UnsupportedDocument("A valid document filename is required.")
if len(value) > 180:
stem, suffix = Path(value).stem[:150], Path(value).suffix[:20]
value = stem + suffix
return value
def _valid_id(value: str) -> str:
try:
return str(uuid.UUID(str(value)))
except (ValueError, TypeError, AttributeError) as exc:
raise NotFound("Project not found.") from exc
class ProjectStore:
def __init__(self, root: str | Path | None, limits: ProjectLimits | None = None):
self.root = Path(root).expanduser().resolve() if root else None
self.limits = limits or ProjectLimits.from_env()
self._lock = threading.RLock()
self._configuration_error = ""
if self.root:
probe = self.root / f".moonley-write-probe-{uuid.uuid4().hex}"
moved_probe = probe.with_suffix(".moved")
try:
self.root.mkdir(parents=True, exist_ok=True)
probe.write_text("ok", encoding="utf-8")
os.replace(probe, moved_probe)
except OSError as exc:
self._configuration_error = type(exc).__name__
finally:
for candidate in (probe, moved_probe):
try:
candidate.unlink(missing_ok=True)
except OSError:
pass
@classmethod
def from_env(cls) -> "ProjectStore":
root = os.environ.get(
"MOONLEY_PROJECT_STORAGE_ROOT",
os.environ.get("THEMIS_PROJECT_STORAGE_ROOT", ""),
)
return cls(root.strip() or None)
@property
def configured(self) -> bool:
return self.root is not None and not self._configuration_error
def status(self) -> dict:
return {
"configured": self.configured,
"persistent": self.configured,
"provider": "mounted_volume" if self.configured else "unconfigured",
"knowledge_ready": self.configured,
"limits": self.limits.public_dict(),
"message": (
"Project files use the configured persistent mounted volume."
if self.configured
else "Attach a read/write persistent volume and set MOONLEY_PROJECT_STORAGE_ROOT."
),
}
def _require_configured(self) -> None:
if not self.configured:
raise StorageUnavailable(self.status()["message"])
@staticmethod
def _owner_key(owner_id: str) -> str:
if not owner_id:
raise ProjectStoreError("Authenticated user identity is required.")
return hashlib.sha256(owner_id.encode("utf-8")).hexdigest()
def _projects_dir(self, owner_id: str) -> Path:
self._require_configured()
assert self.root is not None
return self.root / "users" / self._owner_key(owner_id) / "projects"
def _project_dir(self, owner_id: str, project_id: str) -> Path:
return self._projects_dir(owner_id) / _valid_id(project_id)
@staticmethod
def _manifest_path(project_dir: Path) -> Path:
return project_dir / "project.json"
def _read_manifest(self, owner_id: str, project_id: str) -> dict:
path = self._manifest_path(self._project_dir(owner_id, project_id))
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise NotFound("Project not found.") from exc
return payload
@staticmethod
def _write_manifest(project_dir: Path, payload: dict) -> None:
project_dir.mkdir(parents=True, exist_ok=True)
temp = project_dir / f".project-{uuid.uuid4().hex}.tmp"
temp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(temp, project_dir / "project.json")
@staticmethod
def _public_document(document: dict) -> dict:
return {
key: value
for key, value in document.items()
if key not in {"stored_name", "sha256"}
}
@staticmethod
def _public_project(payload: dict) -> dict:
documents = payload.get("documents") or []
total = sum(int(doc.get("size_bytes") or 0) for doc in documents)
return {
"id": payload["id"],
"name": payload["name"],
"created_at": payload["created_at"],
"updated_at": payload["updated_at"],
"document_count": len(documents),
"knowledge_bytes": total,
"knowledge_ready": any(
document.get("status") in {"ready", "stored"} for document in documents
),
"documents": [ProjectStore._public_document(document) for document in documents],
}
def list_projects(self, owner_id: str) -> list[dict]:
with self._lock:
directory = self._projects_dir(owner_id)
if not directory.exists():
return []
projects = []
for manifest in directory.glob("*/project.json"):
try:
projects.append(self._public_project(json.loads(manifest.read_text(encoding="utf-8"))))
except (OSError, KeyError, json.JSONDecodeError):
continue
return sorted(projects, key=lambda item: item["updated_at"], reverse=True)
def create_project(self, owner_id: str, name: str) -> dict:
with self._lock:
projects = self.list_projects(owner_id)
if len(projects) >= self.limits.max_projects:
raise QuotaExceeded(f"A user can have at most {self.limits.max_projects} projects.")
project_id = str(uuid.uuid4())
timestamp = _now()
payload = {
"version": 1,
"id": project_id,
"name": _clean_name(name),
"created_at": timestamp,
"updated_at": timestamp,
"documents": [],
}
self._write_manifest(self._project_dir(owner_id, project_id), payload)
return self._public_project(payload)
def get_project(self, owner_id: str, project_id: str) -> dict:
with self._lock:
return self._public_project(self._read_manifest(owner_id, project_id))
def rename_project(self, owner_id: str, project_id: str, name: str) -> dict:
with self._lock:
payload = self._read_manifest(owner_id, project_id)
payload["name"] = _clean_name(name)
payload["updated_at"] = _now()
self._write_manifest(self._project_dir(owner_id, project_id), payload)
return self._public_project(payload)
def delete_project(self, owner_id: str, project_id: str) -> None:
with self._lock:
project_dir = self._project_dir(owner_id, project_id)
if not self._manifest_path(project_dir).exists():
raise NotFound("Project not found.")
shutil.rmtree(project_dir)
@staticmethod
def _validate_content(extension: str, content: bytes) -> None:
if not content:
raise UnsupportedDocument("Empty documents cannot be uploaded.")
if extension == ".pdf" and not content.startswith(b"%PDF-"):
raise UnsupportedDocument("The file does not contain a valid PDF header.")
if extension == ".docx":
try:
from io import BytesIO
with zipfile.ZipFile(BytesIO(content)) as archive:
names = set(archive.namelist())
if "[Content_Types].xml" not in names or "word/document.xml" not in names:
raise UnsupportedDocument("The file is not a valid DOCX document.")
except zipfile.BadZipFile as exc:
raise UnsupportedDocument("The file is not a valid DOCX document.") from exc
if extension in {".txt", ".md"}:
try:
content.decode("utf-8-sig")
except UnicodeDecodeError as exc:
raise UnsupportedDocument("Text and Markdown documents must use UTF-8 encoding.") from exc
def _user_bytes(self, owner_id: str) -> int:
return sum(project["knowledge_bytes"] for project in self.list_projects(owner_id))
def add_document(self, owner_id: str, project_id: str, filename: str, content: bytes) -> dict:
with self._lock:
safe_name = _clean_filename(filename)
extension = Path(safe_name).suffix.lower()
if extension not in ALLOWED_EXTENSIONS:
raise UnsupportedDocument("Allowed file types are PDF, DOCX, TXT, and Markdown.")
size = len(content)
if size > self.limits.max_file_bytes:
raise QuotaExceeded(f"Each document must be {self.limits.max_file_bytes // MIB} MiB or smaller.")
self._validate_content(extension, content)
payload = self._read_manifest(owner_id, project_id)
documents = payload.get("documents") or []
digest = hashlib.sha256(content).hexdigest()
duplicate = next((doc for doc in documents if doc.get("sha256") == digest), None)
if duplicate:
return self._public_document(duplicate)
if len(documents) >= self.limits.max_documents:
raise QuotaExceeded(f"A project can contain at most {self.limits.max_documents} documents.")
project_bytes = sum(int(doc.get("size_bytes") or 0) for doc in documents)
if project_bytes + size > self.limits.max_project_bytes:
raise QuotaExceeded(f"Project knowledge is limited to {self.limits.max_project_bytes // MIB} MiB.")
if self._user_bytes(owner_id) + size > self.limits.max_user_bytes:
raise QuotaExceeded(f"User knowledge storage is limited to {self.limits.max_user_bytes // MIB} MiB.")
document_id = str(uuid.uuid4())
project_dir = self._project_dir(owner_id, project_id)
document_dir = project_dir / "documents"
document_dir.mkdir(parents=True, exist_ok=True)
stored_name = document_id + extension
temp = document_dir / f".{document_id}.tmp"
temp.write_bytes(content)
os.replace(temp, document_dir / stored_name)
document = {
"id": document_id,
"name": safe_name,
"size_bytes": size,
"media_type": MEDIA_TYPES[extension],
"sha256": digest,
"status": "stored",
"created_at": _now(),
"stored_name": stored_name,
}
documents.append(document)
payload["documents"] = documents
payload["updated_at"] = document["created_at"]
self._write_manifest(project_dir, payload)
return self._public_document(document)
def delete_document(self, owner_id: str, project_id: str, document_id: str) -> None:
with self._lock:
try:
normalized_id = str(uuid.UUID(str(document_id)))
except (ValueError, TypeError, AttributeError) as exc:
raise NotFound("Document not found.") from exc
payload = self._read_manifest(owner_id, project_id)
documents = payload.get("documents") or []
document = next((item for item in documents if item.get("id") == normalized_id), None)
if not document:
raise NotFound("Document not found.")
project_dir = self._project_dir(owner_id, project_id)
(project_dir / "documents" / document["stored_name"]).unlink(missing_ok=True)
knowledge_dir = project_dir / "knowledge" / normalized_id
if knowledge_dir.exists():
shutil.rmtree(knowledge_dir)
payload["documents"] = [item for item in documents if item.get("id") != normalized_id]
payload["updated_at"] = _now()
self._write_manifest(project_dir, payload)
def document_record(self, owner_id: str, project_id: str, document_id: str) -> dict:
"""Return one private manifest record after owner/project validation."""
with self._lock:
normalized_id = _valid_id(document_id)
payload = self._read_manifest(owner_id, project_id)
document = next(
(item for item in (payload.get("documents") or []) if item.get("id") == normalized_id),
None,
)
if not document:
raise NotFound("Document not found.")
return dict(document)
def document_path(self, owner_id: str, project_id: str, document_id: str) -> Path:
document = self.document_record(owner_id, project_id, document_id)
documents_dir = (self._project_dir(owner_id, project_id) / "documents").resolve()
path = (documents_dir / str(document["stored_name"])).resolve()
if path.parent != documents_dir or not path.is_file():
raise NotFound("Document file not found.")
return path
def knowledge_dir(self, owner_id: str, project_id: str, document_id: str) -> Path:
self.document_record(owner_id, project_id, document_id)
path = self._project_dir(owner_id, project_id) / "knowledge" / _valid_id(document_id)
path.mkdir(parents=True, exist_ok=True)
return path
def record_extraction(
self,
owner_id: str,
project_id: str,
document_id: str,
extraction: dict,
*,
status: str = "ready",
) -> dict:
with self._lock:
normalized_id = _valid_id(document_id)
payload = self._read_manifest(owner_id, project_id)
document = next(
(item for item in (payload.get("documents") or []) if item.get("id") == normalized_id),
None,
)
if not document:
raise NotFound("Document not found.")
document["status"] = status
document["extraction"] = {
key: value
for key, value in extraction.items()
if key in {"method", "pages", "ocr_pages", "truncated", "text_chars", "chunk_count", "vector_provider", "source_provider"}
}
payload["updated_at"] = _now()
self._write_manifest(self._project_dir(owner_id, project_id), payload)
return self._public_document(document)