Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

389 lines
14 KiB
Python

"""Partner workspace layout + asset provisioning.
The partner workspace is a verbatim clone of the chat user-workspace format
(``PathService`` layout), so the chat agent loop's tools read it natively:
data/partners/<id>/workspace/ ← synthetic scope root
├── knowledge_bases/<kb>/ ← copied KBs (rag)
└── user/
├── workspace/
│ ├── SOUL.md ← the partner's persona
│ ├── skills/<name>/SKILL.md ← copied skills (read_skill)
│ ├── notebook/… ← copied notebooks (list_notebook/write_note)
│ └── memory/…
└── settings/ …
Provisioning runs in the *requesting user's* context: sources are resolved
with that user's permissions (``resolve_kb`` / assigned-skill grants), then
copied into the partner scope as plain files. All three asset classes are
self-contained on disk, so a copy is a complete transfer:
* KB: the whole ``<kb>/`` tree (raw + LlamaIndex ``version-N`` dirs); the
partner-side ``KnowledgeBaseManager`` auto-registers it on first list.
* Skill: the whole ``<name>/`` dir (SKILL.md + references).
* Notebook: ``<id>.json`` plus its ``notebooks_index.json`` entry.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
import shutil
from typing import Any
from deeptutor.multi_user.paths import (
ensure_scope_workspace,
get_admin_path_service,
get_path_service_for_scope,
)
from deeptutor.services.partners.scope import partner_scope
from deeptutor.services.path_service import PathService
logger = logging.getLogger(__name__)
def _requester_path_service() -> PathService:
"""Path service for the user driving this provisioning call.
Resolved through ``get_current_user()`` (which falls back to the local
admin) rather than ``get_path_service()`` — the latter short-circuits to
the process-default instance when no user contextvar is set, bypassing
scope resolution entirely.
"""
from deeptutor.multi_user.context import get_current_user
return get_path_service_for_scope(get_current_user().scope)
SOUL_FILENAME = "SOUL.md"
DEFAULT_SOUL = """# Soul
I am a learning companion. I help with questions patiently and clearly,
adapt to the user's level, and value accuracy over speed.
"""
def ensure_partner_workspace(partner_id: str) -> Path:
"""Create the full chat-format workspace tree; returns the scope root."""
return ensure_scope_workspace(partner_scope(partner_id))
def strip_frontmatter(text: str) -> str:
"""Drop a leading YAML frontmatter block (``---`` … ``---``) if present.
Used when cloning a chat persona (PERSONA.md carries name/description
frontmatter) into a partner SOUL.md, which is plain markdown.
"""
raw = (text or "").lstrip()
if not raw.startswith("---"):
return text or ""
end = raw.find("\n---", 3)
if end == -1:
return text or ""
return raw[end + 4 :].lstrip("\n")
def _partner_path_service(partner_id: str) -> PathService:
return PathService(workspace_root=ensure_partner_workspace(partner_id))
# ── Soul ──────────────────────────────────────────────────────────
def soul_path(partner_id: str) -> Path:
return _partner_path_service(partner_id).get_workspace_dir() / SOUL_FILENAME
def read_soul(partner_id: str) -> str:
path = soul_path(partner_id)
if not path.exists():
return ""
try:
return path.read_text(encoding="utf-8")
except OSError:
logger.exception("Failed to read SOUL.md for partner %s", partner_id)
return ""
def write_soul(partner_id: str, content: str) -> None:
path = soul_path(partner_id)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content or "", encoding="utf-8")
# ── Asset provisioning (runs in the requesting user's context) ────
def provision_assets(
partner_id: str,
*,
knowledge_bases: list[str] | None = None,
skills: list[str] | None = None,
notebooks: list[str] | None = None,
) -> dict[str, Any]:
"""Copy the requested assets into the partner workspace.
Source resolution honours the calling user's permissions. Returns a
report dict: ``{"copied": {...}, "errors": [{"type","name","error"}]}``.
"""
root = ensure_partner_workspace(partner_id)
copied: dict[str, list[str]] = {"knowledge_bases": [], "skills": [], "notebooks": []}
errors: list[dict[str, str]] = []
for kb_ref in knowledge_bases or []:
try:
copied["knowledge_bases"].append(_copy_knowledge_base(kb_ref, root))
except Exception as exc:
logger.exception("KB provisioning failed for %s", kb_ref)
errors.append({"type": "knowledge_base", "name": kb_ref, "error": _err(exc)})
for skill_name in skills or []:
try:
copied["skills"].append(_copy_skill(skill_name, partner_id))
except Exception as exc:
logger.exception("Skill provisioning failed for %s", skill_name)
errors.append({"type": "skill", "name": skill_name, "error": _err(exc)})
for notebook_id in notebooks or []:
try:
copied["notebooks"].append(_copy_notebook(notebook_id, partner_id))
except Exception as exc:
logger.exception("Notebook provisioning failed for %s", notebook_id)
errors.append({"type": "notebook", "name": notebook_id, "error": _err(exc)})
return {"copied": copied, "errors": errors}
def _err(exc: Exception) -> str:
detail = getattr(exc, "detail", None)
return str(detail) if detail else f"{type(exc).__name__}: {exc}"
def _copy_knowledge_base(kb_ref: str, partner_root: Path) -> str:
from deeptutor.multi_user.knowledge_access import resolve_kb
resource = resolve_kb(kb_ref)
src = Path(resource.base_dir) / resource.name
if not src.is_dir():
raise FileNotFoundError(f"Knowledge base directory missing: {resource.name}")
dst = partner_root / "knowledge_bases" / resource.name
if dst.exists():
return resource.name # already provisioned
shutil.copytree(src, dst)
return resource.name
def _skill_source_dir(skill_name: str) -> Path:
"""Resolve a skill the calling user may read.
Resolution order mirrors SkillService visibility: the user's own
workspace shadows the packaged builtin set; non-admins may also copy
admin-assigned skills. (Builtins are visible to every partner anyway —
copying one just pins a workspace-local snapshot.)
"""
from deeptutor.multi_user.context import get_current_user
from deeptutor.services.skill.service import BUILTIN_SKILLS_ROOT
own = _requester_path_service().get_workspace_dir() / "skills" / skill_name
if (own / "SKILL.md").exists():
return own
builtin = BUILTIN_SKILLS_ROOT / skill_name
if (builtin / "SKILL.md").exists():
return builtin
user = get_current_user()
if not user.is_admin:
from deeptutor.multi_user.skill_access import assigned_skill_ids
if skill_name in assigned_skill_ids(user.id):
assigned = get_admin_path_service().get_workspace_dir() / "skills" / skill_name
if (assigned / "SKILL.md").exists():
return assigned
raise FileNotFoundError(f"Skill '{skill_name}' not found or not accessible")
def _copy_skill(skill_name: str, partner_id: str) -> str:
src = _skill_source_dir(skill_name)
dst = _partner_path_service(partner_id).get_workspace_dir() / "skills" / skill_name
if dst.exists():
return skill_name
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src, dst)
return skill_name
def _copy_notebook(notebook_id: str, partner_id: str) -> str:
src_dir = _requester_path_service().get_notebook_dir()
src_file = src_dir / f"{notebook_id}.json"
if not src_file.exists():
raise FileNotFoundError(f"Notebook '{notebook_id}' not found")
dst_dir = _partner_path_service(partner_id).get_notebook_dir()
dst_dir.mkdir(parents=True, exist_ok=True)
dst_file = dst_dir / f"{notebook_id}.json"
if not dst_file.exists():
shutil.copy2(src_file, dst_file)
entry = _index_entry(src_dir / "notebooks_index.json", notebook_id)
if entry is None:
# Fall back to a minimal entry derived from the notebook payload.
try:
payload = json.loads(src_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
payload = {}
entry = {
"id": notebook_id,
"name": str(payload.get("name") or notebook_id),
"description": str(payload.get("description") or ""),
"created_at": payload.get("created_at") or 0,
"updated_at": payload.get("updated_at") or 0,
"record_count": len(payload.get("records") or []),
"color": payload.get("color") or "#3B82F6",
"icon": payload.get("icon") or "book",
}
_merge_index_entry(dst_dir / "notebooks_index.json", entry)
return notebook_id
def _index_entry(index_path: Path, notebook_id: str) -> dict[str, Any] | None:
if not index_path.exists():
return None
try:
data = json.loads(index_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
for entry in data.get("notebooks", []) or []:
if str(entry.get("id")) == notebook_id:
return dict(entry)
return None
def _merge_index_entry(index_path: Path, entry: dict[str, Any]) -> None:
data: dict[str, Any] = {"notebooks": []}
if index_path.exists():
try:
loaded = json.loads(index_path.read_text(encoding="utf-8"))
if isinstance(loaded, dict) and isinstance(loaded.get("notebooks"), list):
data = loaded
except (OSError, json.JSONDecodeError):
pass
notebooks = [n for n in data["notebooks"] if str(n.get("id")) != str(entry.get("id"))]
notebooks.append(entry)
data["notebooks"] = notebooks
index_path.parent.mkdir(parents=True, exist_ok=True)
index_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
# ── Asset inventory / removal (partner-side, no user context needed) ──
def list_assets(partner_id: str) -> dict[str, list[dict[str, Any]]]:
root = ensure_partner_workspace(partner_id)
service = _partner_path_service(partner_id)
kbs: list[dict[str, Any]] = []
kb_root = root / "knowledge_bases"
if kb_root.is_dir():
for entry in sorted(kb_root.iterdir()):
if entry.is_dir() and not entry.name.startswith((".", "_")):
raw_count = sum(1 for f in (entry / "raw").glob("*") if f.is_file())
kbs.append({"name": entry.name, "documents": raw_count})
skills: list[dict[str, Any]] = []
skills_root = service.get_workspace_dir() / "skills"
if skills_root.is_dir():
for entry in sorted(skills_root.iterdir()):
if entry.is_dir() and (entry / "SKILL.md").exists():
skills.append({"name": entry.name})
notebooks: list[dict[str, Any]] = []
index_path = service.get_notebook_dir() / "notebooks_index.json"
if index_path.exists():
try:
data = json.loads(index_path.read_text(encoding="utf-8"))
for nb_entry in data.get("notebooks", []) or []:
notebooks.append(
{
"id": str(nb_entry.get("id", "")),
"name": str(nb_entry.get("name", "")),
"record_count": nb_entry.get("record_count", 0),
}
)
except (OSError, json.JSONDecodeError):
pass
return {"knowledge_bases": kbs, "skills": skills, "notebooks": notebooks}
def remove_asset(partner_id: str, asset_type: str, name: str) -> bool:
root = ensure_partner_workspace(partner_id)
service = _partner_path_service(partner_id)
if "/" in name or "\\" in name or name.startswith("."):
raise ValueError("Invalid asset name")
if asset_type == "knowledge_base":
target = root / "knowledge_bases" / name
if not target.is_dir():
return False
shutil.rmtree(target)
config_file = root / "knowledge_bases" / "kb_config.json"
if config_file.exists():
try:
config = json.loads(config_file.read_text(encoding="utf-8"))
if name in config.get("knowledge_bases", {}):
config["knowledge_bases"].pop(name, None)
config_file.write_text(
json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8"
)
except (OSError, json.JSONDecodeError):
logger.warning("Could not prune kb_config entry for %s", name, exc_info=True)
return True
if asset_type == "skill":
target = service.get_workspace_dir() / "skills" / name
if not target.is_dir():
return False
shutil.rmtree(target)
return True
if asset_type == "notebook":
notebook_dir = service.get_notebook_dir()
target = notebook_dir / f"{name}.json"
removed = False
if target.exists():
target.unlink()
removed = True
index_path = notebook_dir / "notebooks_index.json"
if index_path.exists():
try:
data = json.loads(index_path.read_text(encoding="utf-8"))
before = len(data.get("notebooks", []) or [])
data["notebooks"] = [
n for n in data.get("notebooks", []) or [] if str(n.get("id")) != name
]
if len(data["notebooks"]) != before:
removed = True
index_path.write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
)
except (OSError, json.JSONDecodeError):
pass
return removed
raise ValueError(f"Unknown asset type: {asset_type}")
__all__ = [
"DEFAULT_SOUL",
"ensure_partner_workspace",
"list_assets",
"provision_assets",
"read_soul",
"remove_asset",
"soul_path",
"write_soul",
]