Universal-AI-Hub / app /skills_db.py
Alibrown's picture
Upload 58 files
5ef3828 verified
Raw History Blame Contribute Delete
10.1 kB
# =============================================================================
# app/skills_db.py
# Read-only access to the skill databases built by local_tools/vector_hub/secure_vectorizer.py
# Universal MCP Hub (Sandboxed) - based on PyFundaments Architecture
# Copyright 2026 - Volkan KΓΌcΓΌkbudak
# Apache License V. 2 + ESOL 1.1
# =============================================================================
# LAYOUT (app/db/skills/):
# core/core.db β†’ CORE: the main skill with all restrictions. Never exposed through tools.
# Optionally injected as system-prompt prefix (SKILLS_INJECT_CORE / use_core).
# core/<other>.db β†’ AGENT: reference for agents (e.g. core_agent.db). Searchable, not injected.
# user/*.db β†’ USER skills (autoload). Searchable; best chunks can be injected per
# request (SKILLS_INJECT_RELEVANT / use_skills).
#
# WHAT IS USED: FTS5 / BM25 keyword search (same query + weights as the vectorizer).
# The optional semantic vectors (vec BLOB) are ignored β€” no embedding dependency in the hub.
#
# SAFETY:
# - DBs are opened READ-ONLY (mode=ro); nothing here writes, imports or executes skill content.
# - Skill text is plain data that ends up in a system prompt β€” only ship DBs you built yourself.
# - A broken / foreign DB (wrong schema) is skipped with a warning β€” never a crash.
# - SHIP DBs CHECKPOINTED: PRAGMA wal_checkpoint(TRUNCATE); PRAGMA journal_mode=DELETE;
# and do not commit the *.db-wal / *.db-shm files.
# =============================================================================
import asyncio
import logging
import re
import shutil
import sqlite3
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional
from . import config
logger = logging.getLogger("skills_db")
_ROOT = Path(__file__).resolve().parent / "db" / "skills"
_TOKEN = re.compile(r"\w+", re.UNICODE)
_NEEDED = {"chunks", "tags", "meta", "fts"}
_dbs: Dict[str, List[Path]] = {"core": [], "agent": [], "user": []}
_open_as: Dict[Path, tuple] = {} # db path β†’ (target, use_uri)
_core_text = ""
_active = False
# ---------------------------------------------------------------------------
# Open / validate
# ---------------------------------------------------------------------------
def _connect(path: Path) -> sqlite3.Connection:
target, uri = _open_as[path]
return sqlite3.connect(target, uri=uri)
def _validate(path: Path) -> bool:
"""Open read-only, check schema. WAL on a read-only dir β†’ fall back to a private copy."""
attempts = [(f"{path.resolve().as_uri()}?mode=ro", True), None]
for attempt in attempts:
if attempt is None: # fallback: private temp copy (db + wal/shm), opened normally
tmp = Path(tempfile.mkdtemp(prefix="skills_"))
for suffix in ("", "-wal", "-shm"):
src = Path(str(path) + suffix)
if src.exists():
shutil.copy2(src, tmp / src.name)
attempt = (str(tmp / path.name), False)
try:
c = sqlite3.connect(attempt[0], uri=attempt[1])
names = {r[0] for r in c.execute("SELECT name FROM sqlite_master")}
if not _NEEDED <= names:
c.close()
logger.warning(f"Skills DB '{path.name}' skipped β€” not a vectorizer DB (missing {sorted(_NEEDED - names)}).")
return False
c.execute("SELECT 1 FROM chunks LIMIT 1").fetchall()
c.execute("SELECT rowid FROM fts WHERE fts MATCH 'x' LIMIT 1").fetchall() # FTS5 available?
c.close()
_open_as[path] = attempt
return True
except sqlite3.Error as e:
last = type(e).__name__
logger.warning(f"Skills DB '{path.name}' skipped β€” cannot open read-only ({last}).")
return False
def initialize() -> None:
"""Scan app/db/skills/. Inactive or empty β†’ stays inactive. Never raises."""
global _active, _core_text
cfg = config.get_skills()
if str(cfg.get("SKILLS_ACTIVE", "false")).lower() != "true":
logger.info("Skills DB not active in .pyfun [SKILLS] β€” skipped")
return
for k in _dbs:
_dbs[k].clear()
for path in sorted((_ROOT / "core").glob("*.db")) if (_ROOT / "core").is_dir() else []:
if _validate(path):
_dbs["core" if path.name == "core.db" else "agent"].append(path)
for path in sorted((_ROOT / "user").glob("*.db")) if (_ROOT / "user").is_dir() else []:
if _validate(path):
_dbs["user"].append(path)
# core text: all chunks of core.db, in order, capped
try:
cap = int(cfg.get("SKILLS_CORE_MAX_CHARS", "8000"))
except ValueError:
cap = 8000
parts = []
for p in _dbs["core"]:
c = _connect(p)
parts += [f"## {h}\n{t}" if h else t for h, t in c.execute("SELECT heading, text FROM chunks ORDER BY id")]
c.close()
_core_text = "\n\n".join(parts)[:cap]
_active = any(_dbs.values())
logger.info(f"Skills DB: core={len(_dbs['core'])} agent={len(_dbs['agent'])} user={len(_dbs['user'])} "
f"(core text: {len(_core_text)} chars)")
def is_ready() -> bool:
return _active
def core_text() -> str:
"""The always-on core skill (restrictions). For system-prompt injection only β€” never via tools."""
return _core_text
# ---------------------------------------------------------------------------
# Search / catalog (sync, run in a thread by the async wrappers)
# ---------------------------------------------------------------------------
def _fts_query(q: str) -> str:
toks = _TOKEN.findall((q or "").lower())
return " OR ".join(f'"{t}"*' for t in toks)
def _scope_paths(scope: str) -> List[Path]:
scope = (scope or "user").lower()
if scope == "all": # core is deliberately NOT included
return _dbs["agent"] + _dbs["user"]
if scope not in ("user", "agent"):
raise ValueError('scope must be "user", "agent" or "all"')
return _dbs[scope]
def _search_sync(query: str, top_k: int, tag: str, scope: str) -> List[Dict[str, Any]]:
tag = (tag or "").strip().lower() or None
fq = _fts_query(query)
if not fq and not tag:
return []
out: List[Dict[str, Any]] = []
for path in _scope_paths(scope):
c = _connect(path)
try:
if fq:
sql, params = "SELECT rowid FROM fts WHERE fts MATCH ?", [fq]
if tag:
sql += " AND rowid IN (SELECT c.id FROM chunks c JOIN tags t ON t.path=c.path WHERE t.tag=?)"
params.append(tag)
sql += " ORDER BY bm25(fts, 5.0, 1.0) LIMIT ?"
else:
sql = ("SELECT id FROM chunks WHERE path IN (SELECT path FROM tags WHERE tag=?) "
"ORDER BY id LIMIT ?")
params = [tag]
params.append(top_k)
for rank, (rid,) in enumerate(c.execute(sql, params).fetchall()):
row = c.execute(
"SELECT c.path, c.heading, c.text, m.name FROM chunks c "
"LEFT JOIN meta m ON m.path = c.path WHERE c.id = ?", (rid,)).fetchone()
if row:
out.append({"skill": row[0], "name": row[3] or row[0], "heading": row[1],
"text": row[2], "db": path.name, "rank": rank})
finally:
c.close()
out.sort(key=lambda r: r["rank"]) # best of each DB first (BM25 rank per DB)
return out[:top_k]
def _catalog_sync(scope: str, tag: str) -> List[Dict[str, Any]]:
tag = (tag or "").strip().lower() or None
out: List[Dict[str, Any]] = []
for path in _scope_paths(scope):
c = _connect(path)
try:
for p, name, desc in c.execute("SELECT path, name, description FROM meta ORDER BY path"):
tags = [r[0] for r in c.execute("SELECT tag FROM tags WHERE path=? ORDER BY tag", (p,))]
if tag and tag not in tags:
continue
n = c.execute("SELECT COUNT(*) FROM chunks WHERE path=?", (p,)).fetchone()[0]
out.append({"skill": p, "name": name, "description": desc, "tags": tags, "chunks": n, "db": path.name})
finally:
c.close()
return out
async def search(query: str = "", top_k: int = 3, tag: str = "", scope: str = "user") -> List[Dict[str, Any]]:
"""BM25 search over skill chunks. scope: user (default) | agent | all. core is never searchable."""
if not _active:
raise RuntimeError("Skills DB not active β€” set SKILLS_ACTIVE = \"true\" in .pyfun [SKILLS]")
top_k = max(1, min(int(top_k or 3), 10))
return await asyncio.to_thread(_search_sync, query, top_k, tag, scope)
async def catalog(scope: str = "all", tag: str = "") -> List[Dict[str, Any]]:
"""List skills (name, description, tags, chunk count). core is never listed."""
if not _active:
raise RuntimeError("Skills DB not active β€” set SKILLS_ACTIVE = \"true\" in .pyfun [SKILLS]")
return await asyncio.to_thread(_catalog_sync, scope, tag)
async def context_for(prompt: str, top_k: int = 3, max_chars: int = 3000) -> str:
"""Best matching USER skill chunks for a prompt, formatted for a system prompt ('' if none)."""
if not _active or not _dbs["user"] or not (prompt or "").strip():
return ""
try:
hits = await asyncio.to_thread(_search_sync, prompt, max(1, min(int(top_k), 10)), "", "user")
except Exception as e:
logger.warning(f"Skills lookup failed ({type(e).__name__}) β€” continuing without skills.")
return ""
parts, used = [], 0
for h in hits:
block = f"### {h['name']} β€” {h['heading']}\n{h['text']}" if h["heading"] else f"### {h['name']}\n{h['text']}"
if used + len(block) > max_chars:
break
parts.append(block)
used += len(block)
return ("Relevant skills for this request:\n\n" + "\n\n".join(parts)) if parts else ""