Files
Heimgeist/backend/local_rag.py

2366 lines
83 KiB
Python
Raw Normal View History

from __future__ import annotations
import asyncio
import functools
import hashlib
import importlib
import json
import os
import re
import shutil
import threading
import uuid
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from urllib.parse import quote, urlparse
import httpx
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
2026-03-20 12:00:44 +01:00
from .app_settings import (
DEFAULT_EMBED_MODEL as DEFAULT_EMBED_MODEL_SETTING,
DEFAULT_ENRICHMENT_MODEL,
get_auto_deep_enrichment_preference,
2026-03-20 12:00:44 +01:00
get_embed_model_preference,
get_enrichment_model_preference,
2026-03-20 12:00:44 +01:00
get_ollama_api_url,
)
from .paths import library_root
from .video_ingest import (
UnsupportedVideoUrl,
ingest_video_url,
is_likely_media_url,
probe_video_url,
)
from .websearch import fetch_website_snapshot
router = APIRouter(tags=["local-rag"])
LIB_ROOT = library_root()
GLOBAL_LIBRARY_SLUG = "_global"
GLOBAL_LIBRARY_NAME = "Knowledge"
MIGRATED_LIBRARY_ARCHIVE = "_migrated_libraries"
RAW_CORPUS_PROFILE = "per-file-default-v1"
PREPARE_PROFILE = "selective-enrich-v2"
2026-03-20 12:00:44 +01:00
DEFAULT_EMBED_MODEL = DEFAULT_EMBED_MODEL_SETTING
DEFAULT_ENRICH_MODEL = DEFAULT_ENRICHMENT_MODEL
DEFAULT_ENRICH_MIN_CHARS = 240
DEFAULT_ENRICH_MAX_TEXT = 6000
DEFAULT_ENRICH_CONCURRENCY = max(1, min(4, (os.cpu_count() or 4) // 2))
ITEM_METADATA_VERSION = 2
AUTO_DEEP_ENRICHMENT_DISABLED_KEY = "auto_deep_enrichment_disabled"
JOB_EXECUTOR = ThreadPoolExecutor(max_workers=2)
JOBS: Dict[str, Dict[str, Any]] = {}
LIB_LOCKS: Dict[str, asyncio.Lock] = {}
QUERY_LOCKS: Dict[str, threading.Lock] = {}
class CreateLibraryRequest(BaseModel):
name: str
class RenameLibraryRequest(BaseModel):
name: str
class RegisterPathsRequest(BaseModel):
paths: List[str]
class CreateTextRequest(BaseModel):
title: str
content: str
class UpdateTextRequest(BaseModel):
title: str
content: str
class CreateWebsiteRequest(BaseModel):
url: str
title: Optional[str] = None
class RemoveFileRequest(BaseModel):
rel: str
class UpdateFileEnrichmentRequest(BaseModel):
rel: str
enabled: bool
class EmbedLibraryRequest(BaseModel):
embed_model: Optional[str] = None
2026-03-20 12:00:44 +01:00
ollama: Optional[str] = None
target_chars: int = 2000
overlap_chars: int = 200
concurrency: int = 6
class LibraryContextRequest(BaseModel):
prompt: str
top_k: int = 5
2026-03-20 12:00:44 +01:00
ollama: Optional[str] = None
embed_model: Optional[str] = None
gen_model: str = "qwen3:4b"
2026-03-20 12:00:44 +01:00
def _default_ollama_url() -> str:
return get_ollama_api_url()
def _default_embed_model() -> str:
return get_embed_model_preference()
def _default_enrichment_model() -> str:
return get_enrichment_model_preference()
2026-03-20 12:00:44 +01:00
def _resolve_ollama_url(value: Optional[str] = None) -> str:
if isinstance(value, str) and value.strip():
return value.strip().rstrip("/")
return _default_ollama_url()
def now_iso() -> str:
return datetime.utcnow().isoformat(timespec="seconds") + "Z"
def slugify(name: str) -> str:
cleaned = re.sub(r"[^a-zA-Z0-9\- ]+", "", name).strip().lower()
cleaned = re.sub(r"\s+", "-", cleaned)
return cleaned or f"lib-{uuid.uuid4().hex[:8]}"
def lib_dir(slug: str) -> Path:
return LIB_ROOT / slug
def lib_json(slug: str) -> Path:
return lib_dir(slug) / "library.json"
def global_library_slug() -> str:
return GLOBAL_LIBRARY_SLUG
def stage_dir(slug: str) -> Path:
path = lib_dir(slug) / "stage"
path.mkdir(parents=True, exist_ok=True)
return path
def sources_dir(slug: str) -> Path:
path = lib_dir(slug) / "sources"
path.mkdir(parents=True, exist_ok=True)
return path
def indexes_dir(slug: str) -> Path:
path = lib_dir(slug) / "indexes"
path.mkdir(parents=True, exist_ok=True)
return path
def default_library_data(name: str, slug: str) -> Dict[str, Any]:
return {
"id": uuid.uuid4().hex,
"name": name,
"slug": slug,
"created_at": now_iso(),
"files": [],
"pipeline": {},
}
def _read_json(path: Path) -> Dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def read_library(slug: str) -> Dict[str, Any]:
path = lib_json(slug)
if not path.exists():
raise HTTPException(status_code=404, detail="Library not found")
data = _read_json(path)
needs_metadata_upgrade = any(
isinstance(entry.get("metadata"), dict)
and entry["metadata"].get("status") in {"ready", "fallback"}
and int(entry["metadata"].get("version") or 0) < ITEM_METADATA_VERSION
for entry in data.get("files", [])
)
enhanced_path = lib_dir(slug) / "corpus.enhanced.jsonl"
if needs_metadata_upgrade and enhanced_path.exists():
_persist_item_metadata(slug, enhanced_path)
data = _read_json(path)
return data
def write_library(slug: str, data: Dict[str, Any]) -> None:
path = lib_json(slug)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
tmp.replace(path)
def _line_count(path: Path) -> int:
if not path.exists():
return 0
with path.open("r", encoding="utf-8", errors="ignore") as handle:
return sum(1 for line in handle if line.strip())
def _file_uri(path_value: str) -> str:
return f"file://{quote(path_value)}"
def _pipeline_meta(data: Dict[str, Any]) -> Dict[str, Any]:
pipeline = data.get("pipeline")
if isinstance(pipeline, dict):
return pipeline
return {}
def _source_signature(files: List[Dict[str, Any]]) -> Optional[str]:
if not files:
return None
digest = hashlib.sha256()
ordered = sorted(
files,
key=lambda entry: (
str(entry.get("sha256") or ""),
str(entry.get("path") or ""),
str(entry.get("rel") or ""),
),
)
for entry in ordered:
payload = {
"sha256": entry.get("sha256") or "",
"path": entry.get("path") or "",
"rel": entry.get("rel") or "",
"size": int(entry.get("size") or 0),
}
if entry.get("managed") or entry.get("kind") in {"text", "website", "video", "chat_message"}:
payload.update({
"item_id": entry.get("item_id") or "",
"kind": entry.get("kind") or "file",
"title": entry.get("title") or entry.get("name") or "",
"url": entry.get("url") or "",
})
digest.update(json.dumps(payload, sort_keys=True).encode("utf-8"))
digest.update(b"\n")
return digest.hexdigest()
def _file_enrich_enabled(entry: Dict[str, Any]) -> bool:
value = entry.get("enrich_enabled")
if isinstance(value, bool):
return value
if value is None:
return False
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def _versioned_signature(*parts: Optional[str]) -> Optional[str]:
clean_parts = [str(part) for part in parts if part]
if not clean_parts:
return None
digest = hashlib.sha256()
for part in clean_parts:
digest.update(part.encode("utf-8"))
digest.update(b"\n")
return digest.hexdigest()
def _corpus_signature(files: List[Dict[str, Any]]) -> Optional[str]:
return _versioned_signature("corpus", RAW_CORPUS_PROFILE, _source_signature(files))
def _prepare_signature(files: List[Dict[str, Any]]) -> Optional[str]:
source_signature = _source_signature(files)
if not source_signature:
return None
digest = hashlib.sha256()
digest.update(f"prepare\n{RAW_CORPUS_PROFILE}\n{PREPARE_PROFILE}\n{source_signature}\n".encode("utf-8"))
for entry in sorted(files, key=lambda item: str(item.get("rel") or item.get("path") or item.get("sha256") or "")):
rel = str(entry.get("rel") or "")
enabled = "1" if _file_enrich_enabled(entry) else "0"
digest.update(f"{rel}\t{enabled}\n".encode("utf-8"))
return digest.hexdigest()
def _collect_library_paths(slug: str) -> Dict[str, Path]:
base = lib_dir(slug)
return {
"base": base,
"stage": stage_dir(slug),
"corpus": base / "corpus.jsonl",
"enrich_input": base / "corpus.enrich-input.jsonl",
"enhanced": base / "corpus.enhanced.jsonl",
"shadow": base / "corpus.shadow.jsonl",
"shadow_partial": base / "corpus.shadow.partial.jsonl",
"indexes": indexes_dir(slug),
"shadow_index": indexes_dir(slug) / "shadow.index.faiss",
"shadow_store": indexes_dir(slug) / "shadow.meta.jsonl",
"content_index": indexes_dir(slug) / "content.index.faiss",
"content_store": indexes_dir(slug) / "content.meta.jsonl",
}
def _cleanup_enrichment_artifacts(slug: str) -> None:
paths = _collect_library_paths(slug)
for key in ("enrich_input", "enhanced", "shadow", "shadow_partial"):
target = paths[key]
if target.exists():
target.unlink()
def _cleanup_generated_artifacts(slug: str) -> None:
paths = _collect_library_paths(slug)
for key in (
"corpus",
"enrich_input",
"enhanced",
"shadow",
"shadow_partial",
"shadow_index",
"shadow_store",
"content_index",
"content_store",
):
target = paths[key]
if target.exists():
target.unlink()
def _latest_library_job(slug: str, *, statuses: Optional[set[str]] = None) -> Optional[Dict[str, Any]]:
matches = [
job for job in JOBS.values()
if job["slug"] == slug and (statuses is None or job["status"] in statuses)
]
if not matches:
return None
matches.sort(key=lambda job: (str(job.get("created_at") or ""), job["id"]), reverse=True)
return matches[0]
def _build_file_sync_payload(
slug: str,
files: List[Dict[str, Any]],
pipeline: Dict[str, Any],
) -> List[Dict[str, Any]]:
active_job = _latest_library_job(slug, statuses={"queued", "running"})
failed_job = _latest_library_job(slug, statuses={"failed"})
pending_signature = pipeline.get("pending_prepare_signature")
out: List[Dict[str, Any]] = []
for entry in files:
file_entry = dict(entry)
file_entry["item_id"] = str(file_entry.get("item_id") or file_entry.get("sha256") or file_entry.get("rel") or "")
file_entry["kind"] = str(file_entry.get("kind") or "file")
file_entry["title"] = str(file_entry.get("title") or file_entry.get("name") or "")
file_entry["enrich_enabled"] = _file_enrich_enabled(file_entry)
stored_status = str(file_entry.get("sync_status") or "pending")
sync_status = stored_status
sync_progress = 100.0 if stored_status == "ready" else 0.0
sync_detail = ""
sync_error = file_entry.get("sync_error")
if stored_status != "ready":
if active_job:
sync_status = "syncing"
sync_progress = float(active_job.get("progress") or 0.0)
sync_detail = active_job.get("detail") or ""
sync_error = None
elif failed_job and pending_signature:
sync_status = "failed"
sync_progress = 0.0
sync_detail = failed_job.get("detail") or ""
sync_error = failed_job.get("error")
elif pending_signature:
sync_status = "pending"
sync_progress = 0.0
file_entry["sync"] = {
"status": sync_status,
"progress": round(sync_progress, 1),
"detail": sync_detail,
"error": sync_error,
"ready": sync_status == "ready",
}
out.append(file_entry)
return out
def library_payload(data: Dict[str, Any]) -> Dict[str, Any]:
paths = _collect_library_paths(data["slug"])
pipeline = _pipeline_meta(data)
raw_files = list(data.get("files", []))
files = _build_file_sync_payload(data["slug"], raw_files, pipeline)
source_signature = _source_signature(files)
corpus_signature = _corpus_signature(files)
prepare_signature = _prepare_signature(files)
enrichment_enabled_files = sum(1 for entry in files if _file_enrich_enabled(entry))
has_corpus = bool(corpus_signature) and pipeline.get("corpus_signature") == corpus_signature and paths["corpus"].exists()
has_metadata = (
bool(prepare_signature)
and pipeline.get("enriched_signature") == prepare_signature
and paths["enhanced"].exists()
and paths["shadow_partial"].exists()
)
is_enriched = has_metadata and enrichment_enabled_files > 0
is_indexed = (
bool(prepare_signature)
and pipeline.get("indexed_signature") == prepare_signature
and paths["shadow_index"].exists()
and paths["shadow_store"].exists()
and paths["content_index"].exists()
and paths["content_store"].exists()
)
stages = {
"has_files": len(files) > 0,
"has_corpus": has_corpus,
"has_metadata": has_metadata,
"is_enriched": is_enriched,
"is_indexed": is_indexed,
"is_ready_for_chat": is_indexed,
"needs_prepare": bool(files) and not is_indexed,
"enrichment_enabled_files": enrichment_enabled_files,
}
artifacts = {
"corpus_records": _line_count(paths["corpus"]) if has_corpus else 0,
"enhanced_records": _line_count(paths["enhanced"]) if has_metadata else 0,
"shadow_records": _line_count(paths["shadow_partial"]) if has_metadata else 0,
}
return {
**data,
"files": files,
"pipeline": pipeline,
"source_signature": source_signature,
"corpus_signature": corpus_signature,
"prepare_signature": prepare_signature,
"states": stages,
"artifacts": artifacts,
}
def _walk_input_paths(paths: List[str]) -> List[Path]:
out: List[Path] = []
for raw in paths:
current = Path(raw).expanduser().resolve()
if not current.exists():
continue
if current.is_file():
out.append(current)
continue
for child in current.rglob("*"):
if child.is_file():
out.append(child.resolve())
return out
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _write_text_atomic(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(content, encoding="utf-8")
tmp.replace(path)
def _managed_source_path(slug: str, item_id: str, suffix: str = ".md") -> Path:
return sources_dir(slug) / f"{item_id}{suffix}"
def _managed_stage_rel(item_id: str, suffix: str = ".md") -> str:
return f"managed-{item_id}{suffix}"
def _ensure_stage_link(slug: str, source_path: Path, rel: str) -> None:
staged = stage_dir(slug) / rel
if staged.is_symlink() or staged.exists():
staged.unlink()
staged.symlink_to(source_path)
def _find_item(data: Dict[str, Any], item_id: str) -> Optional[Dict[str, Any]]:
return next(
(
entry for entry in data.get("files", [])
if str(entry.get("item_id") or entry.get("sha256") or entry.get("rel") or "") == item_id
),
None,
)
def _read_indexed_file_text(slug: str, entry: Dict[str, Any], max_chars: int = 200_000) -> tuple[str, bool]:
corpus_path = _collect_library_paths(slug)["corpus"]
if not corpus_path.exists():
return "", False
raw_path = str(entry.get("path") or "")
try:
source_key = str(Path(raw_path).expanduser().resolve())
except Exception:
source_key = raw_path
item_id = str(entry.get("item_id") or entry.get("sha256") or entry.get("rel") or "")
parts: List[str] = []
total = 0
truncated = False
with corpus_path.open("r", encoding="utf-8", errors="ignore") as handle:
for line in handle:
if not line.strip():
continue
try:
record = json.loads(line)
except Exception:
continue
record_source = str(record.get("source_path") or "")
try:
record_source_key = str(Path(record_source).expanduser().resolve())
except Exception:
record_source_key = record_source
record_item_id = str((record.get("meta") or {}).get("item_id") or record.get("id") or "")
if record_source_key != source_key and (not item_id or record_item_id != item_id):
continue
text = str(record.get("text") or "").strip()
if not text:
continue
remaining = max_chars - total
if remaining <= 0:
truncated = True
break
if len(text) > remaining:
parts.append(text[:remaining].rstrip())
truncated = True
break
parts.append(text)
total += len(text) + 2
return "\n\n".join(parts), truncated
def _ensure_global_library() -> Dict[str, Any]:
if lib_json(GLOBAL_LIBRARY_SLUG).exists():
return read_library(GLOBAL_LIBRARY_SLUG)
data = default_library_data(GLOBAL_LIBRARY_NAME, GLOBAL_LIBRARY_SLUG)
data["system_managed"] = True
stage_dir(GLOBAL_LIBRARY_SLUG)
indexes_dir(GLOBAL_LIBRARY_SLUG)
sources_dir(GLOBAL_LIBRARY_SLUG)
write_library(GLOBAL_LIBRARY_SLUG, data)
return data
def _source_library_dirs() -> List[Path]:
out: List[Path] = []
if not LIB_ROOT.exists():
return out
for path in LIB_ROOT.iterdir():
if not path.is_dir() or path.name in {GLOBAL_LIBRARY_SLUG, MIGRATED_LIBRARY_ARCHIVE}:
continue
if (path / "library.json").exists():
out.append(path)
return out
def _migration_archive_root() -> Path:
path = LIB_ROOT / MIGRATED_LIBRARY_ARCHIVE
path.mkdir(parents=True, exist_ok=True)
return path
def _archive_library_dir(path: Path) -> None:
archive_root = _migration_archive_root()
target = archive_root / path.name
if target.exists():
stamp = datetime.utcnow().strftime("%Y%m%d%H%M%S")
target = archive_root / f"{path.name}-{stamp}"
index = 2
while target.exists():
target = archive_root / f"{path.name}-{stamp}-{index}"
index += 1
shutil.move(str(path), str(target))
def _entry_dedupe_key(entry: Dict[str, Any]) -> Optional[tuple[str, str]]:
kind = str(entry.get("kind") or "file")
source_message_id = str(entry.get("source_message_id") or "").strip()
if kind == "chat_message" and source_message_id:
return ("chat_message", source_message_id)
url = str(entry.get("url") or entry.get("final_url") or entry.get("requested_url") or "").strip()
if kind in {"website", "video"} and url:
return (kind, url)
sha = str(entry.get("sha256") or "").strip()
path = str(entry.get("path") or "").strip()
if sha and path:
return (kind, f"{sha}:{path}")
return None
def _merge_entry_origin(entry: Dict[str, Any], source_library: Dict[str, Any]) -> None:
slug = str(source_library.get("slug") or "").strip()
name = str(source_library.get("name") or slug or "Imported collection").strip()
if not slug:
return
entry.setdefault("origin_library_slug", slug)
entry.setdefault("origin_library_name", name)
source_libraries = entry.get("source_libraries")
if not isinstance(source_libraries, list):
source_libraries = []
if not any(isinstance(item, dict) and item.get("slug") == slug for item in source_libraries):
source_libraries.append({"slug": slug, "name": name})
entry["source_libraries"] = source_libraries
collections = entry.get("collections")
if not isinstance(collections, list):
collections = []
if name and name not in collections:
collections.append(name)
entry["collections"] = collections
def _existing_entry_indexes(files: List[Dict[str, Any]]) -> tuple[set[str], set[str], Dict[tuple[str, str], Dict[str, Any]]]:
item_ids = {str(entry.get("item_id") or "") for entry in files if str(entry.get("item_id") or "").strip()}
rels = {str(entry.get("rel") or "") for entry in files if str(entry.get("rel") or "").strip()}
dedupe: Dict[tuple[str, str], Dict[str, Any]] = {}
for entry in files:
key = _entry_dedupe_key(entry)
if key:
dedupe[key] = entry
return item_ids, rels, dedupe
def _unique_item_id(existing_ids: set[str], preferred: Optional[str] = None) -> str:
value = str(preferred or "").strip() or uuid.uuid4().hex
while value in existing_ids:
value = uuid.uuid4().hex
existing_ids.add(value)
return value
def _unique_rel(existing_rels: set[str], preferred: str) -> str:
rel = re.sub(r"[/\\]+", "_", str(preferred or "").strip()) or f"managed-{uuid.uuid4().hex}.md"
if rel not in existing_rels and not (stage_dir(GLOBAL_LIBRARY_SLUG) / rel).exists():
existing_rels.add(rel)
return rel
stem = Path(rel).stem or "source"
suffix = Path(rel).suffix
index = 2
while True:
candidate = f"{stem}-{index}{suffix}"
if candidate not in existing_rels and not (stage_dir(GLOBAL_LIBRARY_SLUG) / candidate).exists():
existing_rels.add(candidate)
return candidate
index += 1
def _is_managed_source_from_library(source_slug: str, source_path: Path) -> bool:
try:
source_path.resolve().relative_to(sources_dir(source_slug).resolve())
return True
except Exception:
return False
def _fallback_source_content(source_slug: str, entry: Dict[str, Any]) -> str:
kind = str(entry.get("kind") or "")
raw_path = Path(str(entry.get("path") or ""))
if kind in {"text", "website", "video", "chat_message"} and raw_path.exists():
try:
return raw_path.read_text(encoding="utf-8")
except Exception:
pass
content, _truncated = _read_indexed_file_text(source_slug, entry)
return content
def _migrate_entry_to_global(
source_slug: str,
source_library: Dict[str, Any],
entry: Dict[str, Any],
global_files: List[Dict[str, Any]],
existing_ids: set[str],
existing_rels: set[str],
dedupe: Dict[tuple[str, str], Dict[str, Any]],
) -> bool:
key = _entry_dedupe_key(entry)
if key and key in dedupe:
_merge_entry_origin(dedupe[key], source_library)
return True
migrated = dict(entry)
original_item_id = str(migrated.get("item_id") or migrated.get("sha256") or migrated.get("rel") or "").strip()
item_id = _unique_item_id(existing_ids, original_item_id)
if original_item_id and original_item_id != item_id:
migrated["source_item_id"] = original_item_id
migrated["item_id"] = item_id
source_path = Path(str(migrated.get("path") or ""))
source_exists = source_path.exists()
managed_source = bool(migrated.get("managed")) or (source_exists and _is_managed_source_from_library(source_slug, source_path))
fallback_content = "" if source_exists else _fallback_source_content(source_slug, migrated)
if managed_source or fallback_content:
suffix = source_path.suffix or ".md"
target_path = _managed_source_path(GLOBAL_LIBRARY_SLUG, item_id, suffix)
if source_exists:
target_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_path, target_path)
elif fallback_content.strip():
_write_text_atomic(target_path, fallback_content)
else:
return False
rel = _unique_rel(existing_rels, _managed_stage_rel(item_id, suffix))
_ensure_stage_link(GLOBAL_LIBRARY_SLUG, target_path, rel)
migrated.update({
"path": str(target_path),
"rel": rel,
"managed": True,
"sha256": _sha256_file(target_path),
"size": target_path.stat().st_size,
})
if fallback_content and not source_exists:
migrated["migration_content_fallback"] = True
else:
if not source_exists:
return False
rel = _unique_rel(existing_rels, str(migrated.get("rel") or _stage_name(str(migrated.get("sha256") or ""), source_path)))
_ensure_stage_link(GLOBAL_LIBRARY_SLUG, source_path, rel)
migrated["rel"] = rel
_merge_entry_origin(migrated, source_library)
migrated["migrated_at"] = now_iso()
migrated["sync_status"] = "pending"
migrated.pop("sync_error", None)
migrated.pop("synced_at", None)
global_files.append(migrated)
if key:
dedupe[key] = migrated
return True
def ensure_global_knowledge_store() -> Dict[str, Any]:
global_data = _ensure_global_library()
source_dirs = _source_library_dirs()
if not source_dirs:
return global_data
global_data = read_library(GLOBAL_LIBRARY_SLUG)
global_files = list(global_data.get("files", []))
existing_ids, existing_rels, dedupe = _existing_entry_indexes(global_files)
changed = False
for path in source_dirs:
try:
source_library = _read_json(path / "library.json")
source_slug = str(source_library.get("slug") or path.name)
source_library["slug"] = source_slug
source_library.setdefault("name", path.name)
for entry in source_library.get("files", []):
if isinstance(entry, dict):
changed = _migrate_entry_to_global(
source_slug,
source_library,
entry,
global_files,
existing_ids,
existing_rels,
dedupe,
) or changed
_archive_library_dir(path)
except Exception as exc:
print(f"[knowledge] failed to migrate library {path.name}: {exc}")
if changed:
global_data["files"] = global_files
global_data["updated_at"] = now_iso()
_set_pending_prepare_signature(global_data, _prepare_signature(global_files))
write_library(GLOBAL_LIBRARY_SLUG, global_data)
_cleanup_generated_artifacts(GLOBAL_LIBRARY_SLUG)
return read_library(GLOBAL_LIBRARY_SLUG)
def _mark_entry_pending(entry: Dict[str, Any]) -> None:
entry["sync_status"] = "pending"
entry.pop("sync_error", None)
entry.pop("synced_at", None)
metadata = entry.get("metadata") if isinstance(entry.get("metadata"), dict) else {}
entry["metadata"] = {**metadata, "status": "pending"}
entry["metadata"].pop("error", None)
def _enable_automatic_deep_enrichment(slug: str, data: Optional[Dict[str, Any]] = None) -> bool:
if not get_auto_deep_enrichment_preference():
return False
library = data if data is not None else read_library(slug)
changed = False
for entry in library.get("files", []):
metadata = entry.get("metadata") if isinstance(entry.get("metadata"), dict) else {}
if metadata.get("status") not in {"ready", "fallback"}:
continue
if _file_enrich_enabled(entry) or bool(entry.get(AUTO_DEEP_ENRICHMENT_DISABLED_KEY)):
continue
entry["enrich_enabled"] = True
_mark_entry_pending(entry)
changed = True
if not changed:
return False
_set_pending_prepare_signature(library, _prepare_signature(library.get("files", [])))
write_library(slug, library)
return True
async def _save_library_change(slug: str, data: Dict[str, Any]) -> Optional[str]:
signature = _prepare_signature(data.get("files", []))
_set_pending_prepare_signature(data, signature)
write_library(slug, data)
if signature:
return await _ensure_prepare_job(slug)
_cleanup_generated_artifacts(slug)
return None
def _apply_source_metadata(corpus_path: Path, files: List[Dict[str, Any]]) -> None:
if not corpus_path.exists():
return
by_path: Dict[str, Dict[str, Any]] = {}
for entry in files:
raw_path = str(entry.get("path") or "").strip()
if not raw_path:
continue
try:
by_path[str(Path(raw_path).expanduser().resolve())] = entry
except Exception:
by_path[raw_path] = entry
rewritten: List[str] = []
changed = False
with corpus_path.open("r", encoding="utf-8") as handle:
for line in handle:
if not line.strip():
continue
rec = json.loads(line)
raw_source = str(rec.get("source_path") or "")
try:
source_key = str(Path(raw_source).expanduser().resolve())
except Exception:
source_key = raw_source
entry = by_path.get(source_key)
if entry and entry.get("item_id"):
item_id = str(entry["item_id"])
rec["id"] = item_id
rec["parent_id"] = None
rec["title"] = entry.get("title") or entry.get("name") or rec.get("title")
rec["url"] = entry.get("url") or rec.get("url")
rec["record_type"] = entry.get("kind") or rec.get("record_type")
meta = rec.get("meta") if isinstance(rec.get("meta"), dict) else {}
rec["meta"] = {**meta, "item_id": item_id, "source_kind": entry.get("kind") or "file"}
changed = True
rewritten.append(json.dumps(rec, ensure_ascii=False) + "\n")
if changed:
tmp = corpus_path.with_suffix(".metadata.tmp")
tmp.write_text("".join(rewritten), encoding="utf-8")
tmp.replace(corpus_path)
def _persist_item_metadata(slug: str, enhanced_path: Path) -> None:
if not enhanced_path.exists():
return
path = lib_json(slug)
if not path.exists():
return
data = _read_json(path)
by_path: Dict[str, Dict[str, Any]] = {}
for entry in data.get("files", []):
raw_path = str(entry.get("path") or "")
try:
key = str(Path(raw_path).expanduser().resolve())
except Exception:
key = raw_path
if key:
by_path[key] = entry
changed = False
with enhanced_path.open("r", encoding="utf-8", errors="ignore") as handle:
for line in handle:
if not line.strip():
continue
try:
record = json.loads(line)
except Exception:
continue
raw_source = str(record.get("source_path") or "")
try:
source_key = str(Path(raw_source).expanduser().resolve())
except Exception:
source_key = raw_source
entry = by_path.get(source_key)
if not entry:
continue
enrichment_meta = record.get("enrichment_meta") if isinstance(record.get("enrichment_meta"), dict) else {}
ok = bool(enrichment_meta.get("ok", True))
entry["metadata"] = {
"version": ITEM_METADATA_VERSION,
"status": "ready" if ok else "fallback",
"headline": str(record.get("headline") or ""),
"summary": str(record.get("summary") or ""),
"keywords": list(record.get("keywords") or []),
"entities": list(record.get("entities") or []),
"qa": list(record.get("qa") or []),
"language": record.get("lang"),
"level": str(enrichment_meta.get("level") or "standard"),
"model": enrichment_meta.get("model"),
"strategy": enrichment_meta.get("strategy"),
"qa_count": len(record.get("qa") or []),
"quality_flags": list(enrichment_meta.get("quality_flags") or []),
"updated_at": now_iso(),
"error": enrichment_meta.get("error") if not ok else None,
}
changed = True
if changed:
write_library(slug, data)
def _stage_name(sha: str, path: Path) -> str:
safe_name = re.sub(r"[^A-Za-z0-9._-]+", "_", path.name).strip("._") or "file"
return f"{sha}--{safe_name}"
def _job_public(job: Dict[str, Any]) -> Dict[str, Any]:
return {
"id": job["id"],
"slug": job["slug"],
"type": job["type"],
"status": job["status"],
"phase": job.get("phase"),
"progress": job.get("progress", 0.0),
"detail": job.get("detail", ""),
"error": job.get("error"),
"result": job.get("result"),
"created_at": job["created_at"],
"finished_at": job.get("finished_at"),
}
def _has_active_job(slug: str) -> bool:
return any(
job["slug"] == slug and job["status"] in {"queued", "running"}
for job in JOBS.values()
)
def _load_pipeline_fn(module_name: str, attr: str):
try:
module = importlib.import_module(f"backend.rag.{module_name}")
except ModuleNotFoundError:
module = importlib.import_module(f".rag.{module_name}", package=__package__)
return getattr(module, attr)
def _mark_pipeline_stage(slug: str, stage: str, source_signature: Optional[str]) -> None:
path = lib_json(slug)
if not path.exists():
return
data = _read_json(path)
pipeline = data.get("pipeline")
if not isinstance(pipeline, dict):
pipeline = {}
data["pipeline"] = pipeline
stamp = now_iso()
if stage == "build":
pipeline["corpus_signature"] = source_signature
pipeline["corpus_updated_at"] = stamp
pipeline.pop("enriched_signature", None)
pipeline.pop("enriched_updated_at", None)
pipeline.pop("indexed_signature", None)
pipeline.pop("indexed_updated_at", None)
elif stage == "enrich":
pipeline["enriched_signature"] = source_signature
pipeline["enriched_updated_at"] = stamp
pipeline.pop("indexed_signature", None)
pipeline.pop("indexed_updated_at", None)
elif stage == "embed":
pipeline["indexed_signature"] = source_signature
pipeline["indexed_updated_at"] = stamp
else:
raise ValueError(f"Unknown pipeline stage: {stage}")
write_library(slug, data)
def _set_pipeline_embed_model(slug: str, embed_model: Optional[str]) -> None:
if not embed_model:
return
path = lib_json(slug)
if not path.exists():
return
data = _read_json(path)
pipeline = data.get("pipeline")
if not isinstance(pipeline, dict):
pipeline = {}
data["pipeline"] = pipeline
pipeline["embed_model"] = embed_model
pipeline["embed_model_updated_at"] = now_iso()
write_library(slug, data)
def _set_pending_prepare_signature(data: Dict[str, Any], source_signature: Optional[str]) -> None:
pipeline = data.get("pipeline")
if not isinstance(pipeline, dict):
pipeline = {}
data["pipeline"] = pipeline
if source_signature:
pipeline["pending_prepare_signature"] = source_signature
pipeline["pending_prepare_updated_at"] = now_iso()
else:
pipeline.pop("pending_prepare_signature", None)
pipeline.pop("pending_prepare_updated_at", None)
def _clear_pending_prepare(slug: str) -> None:
path = lib_json(slug)
if not path.exists():
return
data = _read_json(path)
_set_pending_prepare_signature(data, None)
write_library(slug, data)
def _mark_all_files_ready(slug: str) -> None:
path = lib_json(slug)
if not path.exists():
return
data = _read_json(path)
stamp = now_iso()
changed = False
for entry in data.get("files", []):
if entry.get("sync_status") != "ready" or entry.get("synced_at") != stamp:
changed = True
entry["sync_status"] = "ready"
entry["synced_at"] = stamp
entry.pop("sync_error", None)
if changed:
write_library(slug, data)
def _mark_pending_files_failed(slug: str, error: Optional[str]) -> None:
path = lib_json(slug)
if not path.exists():
return
data = _read_json(path)
changed = False
for entry in data.get("files", []):
if entry.get("sync_status") == "ready":
continue
entry["sync_status"] = "failed"
if error:
entry["sync_error"] = error
else:
entry.pop("sync_error", None)
changed = True
if changed:
write_library(slug, data)
def _mark_unfinished_metadata_failed(slug: str, error: Optional[str]) -> None:
path = lib_json(slug)
if not path.exists():
return
data = _read_json(path)
changed = False
for entry in data.get("files", []):
metadata = entry.get("metadata") if isinstance(entry.get("metadata"), dict) else {}
if metadata.get("status") in {"ready", "fallback"}:
continue
entry["metadata"] = {**metadata, "status": "failed", "error": error or "Metadata generation failed."}
changed = True
if changed:
write_library(slug, data)
async def _ensure_prepare_job(slug: str) -> Optional[str]:
path = lib_json(slug)
if not path.exists():
return None
lock = LIB_LOCKS.setdefault(slug, asyncio.Lock())
async with lock:
if _has_active_job(slug):
return None
data = read_library(slug)
payload = library_payload(data)
pipeline = _pipeline_meta(data)
pending_signature = pipeline.get("pending_prepare_signature")
if not payload["states"].get("has_files") or not pending_signature:
return None
if payload["states"].get("is_indexed"):
_mark_all_files_ready(slug)
_clear_pending_prepare(slug)
return None
return _start_job(slug, "prepare")
async def _handle_post_job_state(slug: str, job_type: str, status: str) -> None:
path = lib_json(slug)
if not path.exists():
return
data = read_library(slug)
payload = library_payload(data)
pipeline = _pipeline_meta(data)
pending_signature = pipeline.get("pending_prepare_signature")
if not payload["states"].get("has_files"):
_clear_pending_prepare(slug)
_cleanup_generated_artifacts(slug)
return
if payload["states"].get("is_indexed"):
if job_type == "prepare" and status == "succeeded" and _enable_automatic_deep_enrichment(slug, data):
await _ensure_prepare_job(slug)
return
_mark_all_files_ready(slug)
_clear_pending_prepare(slug)
return
if status == "failed":
failed_job = _latest_library_job(slug, statuses={"failed"})
_mark_pending_files_failed(slug, failed_job.get("error") if failed_job else None)
return
if status == "succeeded" and pending_signature and not payload["states"].get("is_indexed"):
await _ensure_prepare_job(slug)
def _scaled_progress(on_progress, start: float, end: float, prefix: str):
def wrapped(phase: str, pct: float, detail: str):
clamped = min(1.0, max(0.0, float(pct)))
span = max(0.0, end - start)
message = f"{prefix}: {detail}" if detail else prefix
on_progress(phase, start + (span * clamped), message)
return wrapped
def _selected_enrichment_paths(files: List[Dict[str, Any]]) -> set[str]:
return {
str(entry.get("path") or "")
for entry in files
if _file_enrich_enabled(entry) and str(entry.get("path") or "").strip()
}
def _shadow_size_metrics(text: str) -> Dict[str, int]:
return {
"char_count": len(text),
"word_count": len(text.split()),
"line_count": len(text.splitlines()),
}
def _build_raw_shadow_record(rec: Dict[str, Any]) -> Dict[str, Any]:
synth_shadow_from_raw = _load_pipeline_fn("index_builder", "synth_shadow_from_raw")
shadow_text = synth_shadow_from_raw(rec)
return {
"id": rec.get("id"),
"parent_id": rec.get("parent_id"),
"source_path": rec.get("source_path"),
"url": rec.get("url"),
"title": rec.get("title"),
"record_type": rec.get("record_type"),
"mime": rec.get("mime"),
"lang": rec.get("lang"),
"span": rec.get("span") if isinstance(rec.get("span"), dict) else None,
"shadow_text": shadow_text,
"shadow_meta": {
"prompt_version": "raw-fallback",
"size": _shadow_size_metrics(shadow_text),
},
}
def _write_selected_corpus(corpus_path: Path, out_path: Path, selected_paths: set[str]) -> int:
if not selected_paths or not corpus_path.exists():
if out_path.exists():
out_path.unlink()
return 0
out_path.parent.mkdir(parents=True, exist_ok=True)
written = 0
with corpus_path.open("r", encoding="utf-8") as src, out_path.open("w", encoding="utf-8") as dst:
for line in src:
if not line.strip():
continue
try:
rec = json.loads(line)
except Exception:
continue
source_path = str(rec.get("source_path") or "")
if source_path not in selected_paths:
continue
dst.write(json.dumps(rec, ensure_ascii=False) + "\n")
written += 1
if written == 0 and out_path.exists():
out_path.unlink()
return written
def _build_merged_shadow_corpus(corpus_path: Path, partial_shadow_path: Path, out_path: Path) -> int:
if not corpus_path.exists():
if out_path.exists():
out_path.unlink()
return 0
selected_shadow: Dict[str, Dict[str, Any]] = {}
if partial_shadow_path.exists():
with partial_shadow_path.open("r", encoding="utf-8") as src:
for line in src:
if not line.strip():
continue
try:
rec = json.loads(line)
except Exception:
continue
rec_id = str(rec.get("id") or rec.get("record_id") or "")
if rec_id:
selected_shadow[rec_id] = rec
out_path.parent.mkdir(parents=True, exist_ok=True)
written = 0
with corpus_path.open("r", encoding="utf-8") as src, out_path.open("w", encoding="utf-8") as dst:
for line in src:
if not line.strip():
continue
try:
rec = json.loads(line)
except Exception:
continue
rec_id = str(rec.get("id") or rec.get("record_id") or "")
shadow_record = selected_shadow.get(rec_id) if rec_id else None
if shadow_record is None:
shadow_record = _build_raw_shadow_record(rec)
dst.write(json.dumps(shadow_record, ensure_ascii=False) + "\n")
written += 1
if written == 0 and out_path.exists():
out_path.unlink()
return written
def _run_selected_enrichment(slug: str, on_progress=None, **opts) -> Dict[str, Any]:
data = read_library(slug)
files = list(data.get("files", []))
deep_paths = _selected_enrichment_paths(files)
paths = _collect_library_paths(slug)
if not paths["corpus"].exists():
raise RuntimeError("Build the corpus before generating metadata.")
enrich_runner = _load_pipeline_fn("corpus_enricher", "run_enrich")
result = enrich_runner(
inp=paths["corpus"],
out=paths["enhanced"],
shadow_out=paths["shadow_partial"],
on_progress=on_progress,
2026-03-20 12:00:44 +01:00
ollama=_resolve_ollama_url(opts.get("ollama")),
model=opts.get("enrich_model") or _default_enrichment_model() or DEFAULT_ENRICH_MODEL,
summary_lang=opts.get("summary_lang", "auto"),
concurrency=opts.get("enrich_concurrency", DEFAULT_ENRICH_CONCURRENCY),
min_chars=opts.get("min_chars", DEFAULT_ENRICH_MIN_CHARS),
max_text=opts.get("max_text", DEFAULT_ENRICH_MAX_TEXT),
cache_dir=opts.get("cache_dir", str(paths["base"] / ".rag_cache")),
deep_source_paths=deep_paths,
)
_persist_item_metadata(slug, paths["enhanced"])
result["metadata_records"] = _line_count(paths["enhanced"])
result["deep_files"] = len(deep_paths)
result["shadow_records"] = _build_merged_shadow_corpus(paths["corpus"], paths["shadow_partial"], paths["shadow"])
return result
def _run_prepare_pipeline(slug: str, on_progress=None, **opts):
data = read_library(slug)
payload = library_payload(data)
pipeline = _pipeline_meta(data)
files = list(data.get("files", []))
source_signature = payload.get("source_signature")
corpus_signature = payload.get("corpus_signature")
prepare_signature = payload.get("prepare_signature")
if not files or not source_signature or not corpus_signature or not prepare_signature:
raise RuntimeError("Add files before preparing Knowledge.")
paths = _collect_library_paths(slug)
states = dict(payload.get("states") or {})
results: Dict[str, Any] = {}
build_runner = _load_pipeline_fn("corpus_builder", "run_build")
index_runner = _load_pipeline_fn("index_builder", "run_index")
2026-03-20 12:00:44 +01:00
embed_model = opts.get("embed_model") or _default_embed_model() or pipeline.get("embed_model") or DEFAULT_EMBED_MODEL
if on_progress:
on_progress("prepare", 0.01, "Preparing Knowledge for chat...")
if not states.get("has_corpus"):
build_progress = _scaled_progress(on_progress, 0.02, 0.34, "Reading files") if on_progress else None
results["build"] = build_runner(
root=stage_dir(slug),
out=paths["corpus"],
on_progress=build_progress,
emit="per-file",
lang_detect=False,
)
_apply_source_metadata(paths["corpus"], files)
build_errors = list((results.get("build") or {}).get("errors") or [])
if not paths["corpus"].exists() or _line_count(paths["corpus"]) == 0:
if build_errors:
raise RuntimeError(build_errors[0])
raise RuntimeError("Corpus build produced no usable records.")
_mark_pipeline_stage(slug, "build", corpus_signature)
states["has_corpus"] = True
states["is_enriched"] = False
states["is_indexed"] = False
if not states.get("has_metadata"):
enrich_progress = _scaled_progress(on_progress, 0.34, 0.69, "Generating metadata") if on_progress else None
results["enrich"] = _run_selected_enrichment(
slug,
on_progress=enrich_progress,
ollama=_resolve_ollama_url(opts.get("ollama")),
enrich_model=opts.get("enrich_model") or _default_enrichment_model() or DEFAULT_ENRICH_MODEL,
summary_lang=opts.get("summary_lang", "auto"),
enrich_concurrency=opts.get("enrich_concurrency", DEFAULT_ENRICH_CONCURRENCY),
min_chars=opts.get("min_chars", DEFAULT_ENRICH_MIN_CHARS),
max_text=opts.get("max_text", DEFAULT_ENRICH_MAX_TEXT),
)
_mark_pipeline_stage(slug, "enrich", prepare_signature)
states["has_metadata"] = True
states["is_enriched"] = int(states.get("enrichment_enabled_files") or 0) > 0
states["is_indexed"] = False
if not states.get("is_indexed"):
index_progress = _scaled_progress(on_progress, 0.69, 1.0, "Building search indexes") if on_progress else None
results["embed"] = index_runner(
raw=paths["corpus"],
enhanced=None,
shadow=paths["shadow"] if paths["shadow"].exists() else None,
out_dir=paths["indexes"],
on_progress=index_progress,
embed_model=embed_model,
2026-03-20 12:00:44 +01:00
ollama=_resolve_ollama_url(opts.get("ollama")),
target_chars=opts.get("target_chars", 2000),
overlap_chars=opts.get("overlap_chars", 200),
concurrency=opts.get("concurrency", 6),
)
_set_pipeline_embed_model(slug, results["embed"].get("embed_model") if isinstance(results.get("embed"), dict) else None)
_mark_pipeline_stage(slug, "embed", prepare_signature)
if on_progress:
on_progress("done", 1.0, "Knowledge is ready for chat.")
return {
"status": "ok",
"results": results,
"source_signature": source_signature,
"embed_model": (results.get("embed") or {}).get("embed_model") if isinstance(results.get("embed"), dict) else None,
}
async def _run_job(job_id: str, fn_name: str, **kwargs):
loop = asyncio.get_running_loop()
job = JOBS[job_id]
stage_signature = kwargs.pop("stage_signature", None)
def on_progress(phase: str, pct: float, detail: str):
job["phase"] = phase
job["progress"] = round(float(pct) * 100.0, 1)
job["detail"] = detail
job["status"] = "running"
try:
if fn_name == "build":
runner = _load_pipeline_fn("corpus_builder", "run_build")
elif fn_name == "enrich":
runner = functools.partial(_run_selected_enrichment, job["slug"])
elif fn_name == "embed":
runner = _load_pipeline_fn("index_builder", "run_index")
elif fn_name == "prepare":
runner = functools.partial(_run_prepare_pipeline, job["slug"])
else:
raise RuntimeError(f"Unknown job type: {fn_name}")
call = functools.partial(runner, on_progress=on_progress, **kwargs)
result = await loop.run_in_executor(JOB_EXECUTOR, call)
if fn_name in {"embed", "prepare"} and isinstance(result, dict):
embed_model = result.get("embed_model")
if embed_model:
_set_pipeline_embed_model(job["slug"], embed_model)
if fn_name in {"build", "enrich", "embed"} and stage_signature:
_mark_pipeline_stage(job["slug"], fn_name, stage_signature)
job["status"] = "succeeded"
job["progress"] = 100.0
job["phase"] = "done"
job["detail"] = "Completed."
job["result"] = result
except Exception as exc:
job["status"] = "failed"
job["error"] = f"{type(exc).__name__}: {exc}"
if fn_name in {"enrich", "prepare"} and job.get("phase") in {"load", "enrich"}:
_mark_unfinished_metadata_failed(job["slug"], job["error"])
finally:
job["finished_at"] = now_iso()
try:
await _handle_post_job_state(job["slug"], fn_name, job["status"])
except Exception:
pass
def _start_job(slug: str, job_type: str, **kwargs) -> str:
job_id = uuid.uuid4().hex
JOBS[job_id] = {
"id": job_id,
"slug": slug,
"type": job_type,
"status": "queued",
"phase": "queued",
"progress": 0.0,
"detail": "",
"created_at": now_iso(),
"finished_at": None,
"result": None,
"error": None,
}
asyncio.create_task(_run_job(job_id, job_type, **kwargs))
return job_id
def _build_local_context(prompt: str, results: Dict[str, Any], top_k: int = 5) -> Dict[str, Any]:
sources = results.get("sources") or []
selected = sources[: max(1, top_k)]
if not selected:
context_block = (
"<local_rag_context>\n"
"No useful results were found in the selected local knowledge base.\n"
"</local_rag_context>"
)
return {"context_block": context_block, "sources": []}
blocks: List[str] = ["<local_rag_context>"]
file_sources: List[str] = []
seen_sources: set[str] = set()
for idx, source in enumerate(selected, start=1):
title = (source.get("title") or Path(source.get("url") or source.get("doc_id") or f"Source {idx}").name).strip()
snippet = re.sub(r"\s+", " ", (source.get("snippet") or "")).strip()
if len(snippet) > 1400:
snippet = snippet[:1400].rstrip() + "..."
raw_path = source.get("url") or source.get("doc_id") or ""
if raw_path:
source_uri = ""
if os.path.isabs(raw_path):
source_uri = _file_uri(raw_path)
elif str(raw_path).startswith(("http://", "https://")):
source_uri = str(raw_path)
if source_uri and source_uri not in seen_sources:
seen_sources.add(source_uri)
file_sources.append(source_uri)
blocks.append(f"[L{idx}] {title}\n{snippet}")
blocks.append("</local_rag_context>")
blocks.append(
"Use the local knowledge base context when it is relevant. "
"If it does not answer the question, say so clearly instead of inventing details."
)
return {"context_block": "\n".join(blocks), "sources": file_sources}
async def _synced_library_payload(slug: str) -> Dict[str, Any]:
data = read_library(slug)
payload = library_payload(data)
if (
payload["states"].get("is_indexed")
and not _has_active_job(data["slug"])
and _enable_automatic_deep_enrichment(data["slug"], data)
):
await _ensure_prepare_job(data["slug"])
data = read_library(data["slug"])
payload = library_payload(data)
has_failed_item = any(
str(entry.get("sync_status") or "") == "failed"
for entry in data.get("files", [])
)
if (
_pipeline_meta(data).get("pending_prepare_signature")
and not _has_active_job(data["slug"])
and (payload["states"].get("is_indexed") or not has_failed_item)
):
await _ensure_prepare_job(data["slug"])
return library_payload(read_library(slug))
async def _global_library_payload() -> Dict[str, Any]:
ensure_global_knowledge_store()
return await _synced_library_payload(GLOBAL_LIBRARY_SLUG)
@router.get("/libraries")
async def list_libraries():
return {"libraries": [await _global_library_payload()]}
@router.get("/knowledge")
async def get_knowledge():
return await _global_library_payload()
@router.post("/knowledge/files/register")
async def register_knowledge_paths(req: RegisterPathsRequest):
ensure_global_knowledge_store()
return await register_paths(GLOBAL_LIBRARY_SLUG, req)
@router.get("/knowledge/items/{item_id}")
def get_knowledge_item(item_id: str):
ensure_global_knowledge_store()
return get_library_item(GLOBAL_LIBRARY_SLUG, item_id)
@router.post("/knowledge/texts")
async def create_knowledge_text(req: CreateTextRequest):
ensure_global_knowledge_store()
return await create_library_text(GLOBAL_LIBRARY_SLUG, req)
@router.patch("/knowledge/texts/{item_id}")
async def update_knowledge_text(item_id: str, req: UpdateTextRequest):
ensure_global_knowledge_store()
return await update_library_text(GLOBAL_LIBRARY_SLUG, item_id, req)
@router.post("/knowledge/websites")
async def create_knowledge_website(req: CreateWebsiteRequest):
ensure_global_knowledge_store()
return await create_library_website(GLOBAL_LIBRARY_SLUG, req)
@router.post("/knowledge/videos/{item_id}/refresh")
async def refresh_knowledge_video(item_id: str):
ensure_global_knowledge_store()
return await refresh_library_video(GLOBAL_LIBRARY_SLUG, item_id)
@router.post("/knowledge/websites/{item_id}/refresh")
async def refresh_knowledge_website(item_id: str):
ensure_global_knowledge_store()
return await refresh_library_website(GLOBAL_LIBRARY_SLUG, item_id)
@router.delete("/knowledge/files")
async def remove_knowledge_file(req: RemoveFileRequest):
ensure_global_knowledge_store()
return await remove_file(GLOBAL_LIBRARY_SLUG, req)
@router.patch("/knowledge/files/enrichment")
async def update_knowledge_file_enrichment(req: UpdateFileEnrichmentRequest):
ensure_global_knowledge_store()
return await update_file_enrichment(GLOBAL_LIBRARY_SLUG, req)
@router.post("/knowledge/jobs/prepare")
async def prepare_knowledge():
ensure_global_knowledge_store()
return await prepare_library(GLOBAL_LIBRARY_SLUG)
@router.post("/knowledge/context")
def knowledge_context(req: LibraryContextRequest):
ensure_global_knowledge_store()
return library_context(GLOBAL_LIBRARY_SLUG, req)
@router.post("/knowledge/purge")
def purge_knowledge():
return purge_libraries()
@router.post("/libraries")
def create_library(req: CreateLibraryRequest):
slug = slugify(req.name)
base_slug = slug
idx = 2
while lib_dir(slug).exists():
slug = f"{base_slug}-{idx}"
idx += 1
data = default_library_data(req.name, slug)
stage_dir(slug)
indexes_dir(slug)
write_library(slug, data)
return library_payload(data)
@router.post("/libraries/purge")
def purge_libraries():
active_jobs = [
job for job in JOBS.values()
if job["status"] in {"queued", "running"}
]
if active_jobs:
active_slugs = sorted({str(job.get("slug") or "") for job in active_jobs if job.get("slug")})
detail = "Cannot purge Knowledge while sync jobs are still running."
if active_slugs:
detail = f"{detail} Active jobs: {', '.join(active_slugs)}."
raise HTTPException(status_code=409, detail=detail)
removed: List[str] = []
failures: List[str] = []
for path in list(LIB_ROOT.iterdir()):
if not path.is_dir():
continue
try:
shutil.rmtree(path)
removed.append(path.name)
except Exception as exc:
failures.append(f"{path.name}: {type(exc).__name__}: {exc}")
LIB_ROOT.mkdir(parents=True, exist_ok=True)
JOBS.clear()
LIB_LOCKS.clear()
QUERY_LOCKS.clear()
if failures:
preview = "; ".join(failures[:3])
if len(failures) > 3:
preview = f"{preview}; ..."
raise HTTPException(status_code=500, detail=f"Failed to purge some Knowledge data. {preview}")
return {
"ok": True,
"count": len(removed),
"removed": removed,
}
@router.get("/libraries/{slug}")
def get_library(slug: str):
return library_payload(read_library(slug))
@router.patch("/libraries/{slug}")
def rename_library(slug: str, req: RenameLibraryRequest):
data = read_library(slug)
data["name"] = req.name.strip() or data["name"]
write_library(slug, data)
return library_payload(data)
@router.delete("/libraries/{slug}")
def delete_library(slug: str):
path = lib_dir(slug)
if not path.exists():
raise HTTPException(status_code=404, detail="Library not found")
shutil.rmtree(path)
LIB_LOCKS.pop(slug, None)
QUERY_LOCKS.pop(slug, None)
return {"ok": True}
@router.post("/libraries/{slug}/files/register")
async def register_paths(slug: str, req: RegisterPathsRequest):
data = read_library(slug)
stage = stage_dir(slug)
existing = {entry.get("sha256"): entry for entry in data.get("files", [])}
added: List[Dict[str, Any]] = []
skipped: List[str] = []
for file_path in _walk_input_paths(req.paths):
sha = _sha256_file(file_path)
if sha in existing:
skipped.append(str(file_path))
continue
stage_name = _stage_name(sha, file_path)
symlink_path = stage / stage_name
if symlink_path.exists():
symlink_path.unlink()
symlink_path.symlink_to(file_path)
entry = {
"item_id": uuid.uuid4().hex,
"kind": "file",
"sha256": sha,
"path": str(file_path),
"rel": stage_name,
"name": file_path.name,
"size": file_path.stat().st_size,
"added_at": now_iso(),
"sync_status": "pending",
"enrich_enabled": False,
"metadata": {"status": "pending"},
}
data.setdefault("files", []).append(entry)
added.append(entry)
existing[sha] = entry
job_id = None
if added:
_set_pending_prepare_signature(data, _prepare_signature(data.get("files", [])))
write_library(slug, data)
if added:
job_id = await _ensure_prepare_job(slug)
return {
"added": added,
"skipped": skipped,
"job_id": job_id,
"library": library_payload(data),
}
@router.get("/libraries/{slug}/items/{item_id}")
def get_library_item(slug: str, item_id: str):
data = read_library(slug)
entry = _find_item(data, item_id)
if not entry:
raise HTTPException(status_code=404, detail="Content item not found")
payload = dict(entry)
if entry.get("kind") in {"text", "website", "video", "chat_message"}:
path = Path(str(entry.get("path") or ""))
payload["content"] = path.read_text(encoding="utf-8") if path.exists() else ""
if entry.get("kind") == "website":
payload["content_origin"] = "stored_snapshot"
elif entry.get("kind") == "video":
payload["content_origin"] = "stored_video_transcript"
elif entry.get("kind") == "chat_message":
payload["content_origin"] = "stored_chat_message"
else:
payload["content_origin"] = "stored_text"
payload["content_truncated"] = False
else:
content, truncated = _read_indexed_file_text(slug, entry)
payload["content"] = content
payload["content_origin"] = "extracted_text"
payload["content_truncated"] = truncated
return payload
@router.post("/libraries/{slug}/texts")
async def create_library_text(slug: str, req: CreateTextRequest):
data = read_library(slug)
title = str(req.title or "").strip()
content = str(req.content or "").strip()
if not title:
raise HTTPException(status_code=400, detail="Text title is required.")
if not content:
raise HTTPException(status_code=400, detail="Text content is required.")
item_id = uuid.uuid4().hex
source_path = _managed_source_path(slug, item_id)
rel = _managed_stage_rel(item_id)
_write_text_atomic(source_path, content)
_ensure_stage_link(slug, source_path, rel)
entry = {
"item_id": item_id,
"kind": "text",
"title": title[:240],
"name": title[:240],
"path": str(source_path),
"rel": rel,
"sha256": _sha256_file(source_path),
"size": source_path.stat().st_size,
"added_at": now_iso(),
"updated_at": now_iso(),
"sync_status": "pending",
"enrich_enabled": False,
"metadata": {"status": "pending"},
"managed": True,
}
data.setdefault("files", []).append(entry)
job_id = await _save_library_change(slug, data)
return {"item": entry, "job_id": job_id, "library": library_payload(data)}
async def save_chat_message_snapshot(
slug: str,
*,
title: str,
content: str,
source_message_id: str,
source_session_id: str,
source_session_title: str,
source_role: str,
source_created_at: Optional[str],
sources: Optional[List[str]] = None,
saved_by: str = "user",
) -> Dict[str, Any]:
data = read_library(slug)
existing = next(
(
entry for entry in data.get("files", [])
if entry.get("kind") == "chat_message"
and entry.get("source_message_id") == source_message_id
),
None,
)
if existing:
return {
"item": existing,
"job_id": None,
"already_saved": True,
"library": library_payload(data),
}
clean_title = str(title or "").strip()
clean_content = str(content or "").strip()
if not clean_title:
raise HTTPException(status_code=400, detail="A title is required.")
if not clean_content:
raise HTTPException(status_code=400, detail="Message content is required.")
item_id = uuid.uuid4().hex
source_path = _managed_source_path(slug, item_id)
rel = _managed_stage_rel(item_id)
_write_text_atomic(source_path, clean_content)
_ensure_stage_link(slug, source_path, rel)
timestamp = now_iso()
entry = {
"item_id": item_id,
"kind": "chat_message",
"title": clean_title[:240],
"name": clean_title[:240],
"path": str(source_path),
"rel": rel,
"sha256": _sha256_file(source_path),
"size": source_path.stat().st_size,
"added_at": timestamp,
"updated_at": timestamp,
"sync_status": "pending",
"enrich_enabled": False,
"metadata": {"status": "pending"},
"managed": True,
"source_role": source_role,
"source_message_id": source_message_id,
"source_session_id": source_session_id,
"source_session_title": source_session_title,
"source_created_at": source_created_at,
"sources": list(sources or []),
"saved_by": saved_by,
}
data.setdefault("files", []).append(entry)
job_id = await _save_library_change(slug, data)
return {
"item": entry,
"job_id": job_id,
"already_saved": False,
"library": library_payload(data),
}
@router.patch("/libraries/{slug}/texts/{item_id}")
async def update_library_text(slug: str, item_id: str, req: UpdateTextRequest):
data = read_library(slug)
entry = _find_item(data, item_id)
if not entry or entry.get("kind") not in {"text", "chat_message"}:
raise HTTPException(status_code=404, detail="Text item not found")
title = str(req.title or "").strip()
content = str(req.content or "").strip()
if not title or not content:
raise HTTPException(status_code=400, detail="Text title and content are required.")
source_path = Path(str(entry.get("path") or ""))
_write_text_atomic(source_path, content)
_ensure_stage_link(slug, source_path, str(entry.get("rel") or _managed_stage_rel(item_id)))
entry.update({
"title": title[:240],
"name": title[:240],
"sha256": _sha256_file(source_path),
"size": source_path.stat().st_size,
"updated_at": now_iso(),
})
_mark_entry_pending(entry)
job_id = await _save_library_change(slug, data)
return {"item": entry, "job_id": job_id, "library": library_payload(data)}
@router.post("/libraries/{slug}/websites")
async def create_library_website(slug: str, req: CreateWebsiteRequest):
requested_url = str(req.url or "").strip()
parsed_url = urlparse(requested_url)
if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
raise HTTPException(status_code=400, detail="Enter a valid HTTP or HTTPS URL.")
try:
video_metadata = await asyncio.to_thread(probe_video_url, requested_url)
except UnsupportedVideoUrl:
video_metadata = None
except Exception as exc:
if is_likely_media_url(requested_url):
raise HTTPException(status_code=400, detail=f"Could not read video URL: {exc}") from exc
video_metadata = None
if video_metadata:
try:
video = await ingest_video_url(requested_url, video_metadata)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Could not process video: {exc}") from exc
data = read_library(slug)
canonical_url = str(video["url"])
video_id = str(video.get("video_id") or "")
extractor = str(video.get("extractor") or "")
duplicate = next(
(
entry for entry in data.get("files", [])
if entry.get("kind") == "video"
and (
entry.get("url") == canonical_url
or (
video_id
and entry.get("video_id") == video_id
and str(entry.get("extractor") or "") == extractor
)
)
),
None,
)
if duplicate:
raise HTTPException(status_code=409, detail="This video is already saved in Knowledge.")
item_id = uuid.uuid4().hex
source_path = _managed_source_path(slug, item_id)
rel = _managed_stage_rel(item_id)
_write_text_atomic(source_path, str(video["document"]))
_ensure_stage_link(slug, source_path, rel)
custom_title = str(req.title or "").strip()
title = custom_title or str(video.get("title") or canonical_url)
entry = {
"item_id": item_id,
"kind": "video",
"title": title[:240],
"title_locked": bool(custom_title),
"name": title[:240],
"url": canonical_url,
"requested_url": requested_url,
"video_id": video.get("video_id"),
"extractor": video.get("extractor"),
"channel": video.get("channel"),
"duration": video.get("duration"),
"duration_text": video.get("duration_text"),
"upload_date": video.get("upload_date"),
"thumbnail_url": video.get("thumbnail_url"),
"video_summary": video.get("summary"),
"transcript_chars": len(str(video.get("transcript") or "")),
"transcription_model": video.get("transcription_model"),
"transcription_workers": video.get("transcription_workers"),
"transcription_slices": video.get("transcription_slices"),
"summary_model": video.get("summary_model"),
"yt_dlp_version": video.get("yt_dlp_version"),
"fetched_at": video.get("fetched_at") or now_iso(),
"path": str(source_path),
"rel": rel,
"sha256": _sha256_file(source_path),
"size": source_path.stat().st_size,
"added_at": now_iso(),
"updated_at": now_iso(),
"sync_status": "pending",
"enrich_enabled": False,
"metadata": {"status": "pending"},
"managed": True,
}
data.setdefault("files", []).append(entry)
job_id = await _save_library_change(slug, data)
return {
"item": entry,
"job_id": job_id,
"content_kind": "video",
"library": library_payload(data),
}
data = read_library(slug)
try:
snapshot = await fetch_website_snapshot(requested_url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=400, detail=f"Website returned HTTP {exc.response.status_code}.") from exc
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Could not fetch website: {exc}") from exc
canonical_url = str(snapshot["url"])
duplicate = next(
(entry for entry in data.get("files", []) if entry.get("kind") == "website" and entry.get("url") == canonical_url),
None,
)
if duplicate:
raise HTTPException(status_code=409, detail="This website is already saved in Knowledge.")
item_id = uuid.uuid4().hex
source_path = _managed_source_path(slug, item_id)
rel = _managed_stage_rel(item_id)
_write_text_atomic(source_path, str(snapshot["text"]))
_ensure_stage_link(slug, source_path, rel)
custom_title = str(req.title or "").strip()
title = custom_title or str(snapshot.get("title") or canonical_url)
entry = {
"item_id": item_id,
"kind": "website",
"title": title[:240],
"title_locked": bool(custom_title),
"name": title[:240],
"url": canonical_url,
"requested_url": str(snapshot.get("requested_url") or requested_url),
"final_url": str(snapshot.get("final_url") or canonical_url),
"content_type": snapshot.get("content_type"),
"etag": snapshot.get("etag"),
"last_modified": snapshot.get("last_modified"),
"fetched_at": now_iso(),
"path": str(source_path),
"rel": rel,
"sha256": _sha256_file(source_path),
"size": source_path.stat().st_size,
"added_at": now_iso(),
"updated_at": now_iso(),
"sync_status": "pending",
"enrich_enabled": False,
"metadata": {"status": "pending"},
"managed": True,
}
data.setdefault("files", []).append(entry)
job_id = await _save_library_change(slug, data)
return {"item": entry, "job_id": job_id, "content_kind": "website", "library": library_payload(data)}
@router.post("/libraries/{slug}/videos/{item_id}/refresh")
async def refresh_library_video(slug: str, item_id: str):
data = read_library(slug)
entry = _find_item(data, item_id)
if not entry or entry.get("kind") != "video":
raise HTTPException(status_code=404, detail="Video item not found")
url = str(entry.get("url") or entry.get("requested_url") or "").strip()
try:
metadata = await asyncio.to_thread(probe_video_url, url)
if not metadata:
raise RuntimeError("yt-dlp no longer reports playable media for this URL.")
video = await ingest_video_url(url, metadata)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Could not reprocess video: {exc}") from exc
data = read_library(slug)
entry = _find_item(data, item_id)
if not entry or entry.get("kind") != "video":
raise HTTPException(status_code=404, detail="Video item not found")
source_path = Path(str(entry.get("path") or ""))
previous_hash = str(entry.get("sha256") or "")
_write_text_atomic(source_path, str(video["document"]))
_ensure_stage_link(slug, source_path, str(entry.get("rel") or _managed_stage_rel(item_id)))
new_hash = _sha256_file(source_path)
if not entry.get("title_locked"):
entry["title"] = str(video.get("title") or entry.get("title") or url)[:240]
entry["name"] = entry["title"]
entry.update({
"url": video.get("url") or url,
"video_id": video.get("video_id"),
"extractor": video.get("extractor"),
"channel": video.get("channel"),
"duration": video.get("duration"),
"duration_text": video.get("duration_text"),
"upload_date": video.get("upload_date"),
"thumbnail_url": video.get("thumbnail_url"),
"video_summary": video.get("summary"),
"transcript_chars": len(str(video.get("transcript") or "")),
"transcription_model": video.get("transcription_model"),
"transcription_workers": video.get("transcription_workers"),
"transcription_slices": video.get("transcription_slices"),
"summary_model": video.get("summary_model"),
"yt_dlp_version": video.get("yt_dlp_version"),
"fetched_at": video.get("fetched_at") or now_iso(),
"sha256": new_hash,
"size": source_path.stat().st_size,
"updated_at": now_iso(),
})
_mark_entry_pending(entry)
job_id = await _save_library_change(slug, data)
return {
"changed": previous_hash != new_hash,
"item": entry,
"job_id": job_id,
"library": library_payload(data),
}
@router.post("/libraries/{slug}/websites/{item_id}/refresh")
async def refresh_library_website(slug: str, item_id: str):
data = read_library(slug)
entry = _find_item(data, item_id)
if not entry or entry.get("kind") != "website":
raise HTTPException(status_code=404, detail="Website item not found")
try:
snapshot = await fetch_website_snapshot(str(entry.get("url") or entry.get("requested_url") or ""))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=400, detail=f"Website returned HTTP {exc.response.status_code}.") from exc
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Could not refresh website: {exc}") from exc
source_path = Path(str(entry.get("path") or ""))
previous_hash = str(entry.get("sha256") or "")
previous_title = str(entry.get("title") or "")
previous_url = str(entry.get("url") or "")
_write_text_atomic(source_path, str(snapshot["text"]))
next_hash = _sha256_file(source_path)
entry.update({
"url": str(snapshot.get("url") or entry.get("url") or ""),
"final_url": str(snapshot.get("final_url") or entry.get("final_url") or ""),
"content_type": snapshot.get("content_type"),
"etag": snapshot.get("etag"),
"last_modified": snapshot.get("last_modified"),
"fetched_at": now_iso(),
"size": source_path.stat().st_size,
"sha256": next_hash,
"updated_at": now_iso(),
})
if not entry.get("title_locked"):
title = str(snapshot.get("title") or entry.get("title") or entry.get("url") or "")[:240]
entry["title"] = title
entry["name"] = title
job_id = None
changed = next_hash != previous_hash or str(entry.get("title") or "") != previous_title or str(entry.get("url") or "") != previous_url
if changed:
_mark_entry_pending(entry)
job_id = await _save_library_change(slug, data)
else:
write_library(slug, data)
return {"changed": changed, "item": entry, "job_id": job_id, "library": library_payload(data)}
@router.delete("/libraries/{slug}/files")
async def remove_file(slug: str, req: RemoveFileRequest):
data = read_library(slug)
files = list(data.get("files", []))
removed = next((entry for entry in files if entry.get("rel") == req.rel), None)
if not removed:
raise HTTPException(status_code=404, detail="File not found")
data["files"] = [entry for entry in files if entry.get("rel") != req.rel]
symlink_path = stage_dir(slug) / req.rel
if symlink_path.is_symlink() or symlink_path.exists():
symlink_path.unlink()
if removed.get("managed"):
managed_path = Path(str(removed.get("path") or ""))
if managed_path.exists() and managed_path.is_file():
managed_path.unlink()
prepare_signature = _prepare_signature(data.get("files", []))
_set_pending_prepare_signature(data, prepare_signature)
write_library(slug, data)
job_id = None
if prepare_signature:
job_id = await _ensure_prepare_job(slug)
else:
_cleanup_generated_artifacts(slug)
return {"ok": True, "job_id": job_id, "library": library_payload(data)}
@router.patch("/libraries/{slug}/files/enrichment")
async def update_file_enrichment(slug: str, req: UpdateFileEnrichmentRequest):
data = read_library(slug)
files = list(data.get("files", []))
target = next((entry for entry in files if entry.get("rel") == req.rel), None)
if not target:
raise HTTPException(status_code=404, detail="File not found")
desired = bool(req.enabled)
if _file_enrich_enabled(target) == desired:
return {"job_id": None, "library": library_payload(data)}
target["enrich_enabled"] = desired
if desired:
target.pop(AUTO_DEEP_ENRICHMENT_DISABLED_KEY, None)
else:
target[AUTO_DEEP_ENRICHMENT_DISABLED_KEY] = True
target["sync_status"] = "pending"
target.pop("sync_error", None)
target.pop("synced_at", None)
metadata = target.get("metadata") if isinstance(target.get("metadata"), dict) else {}
target["metadata"] = {**metadata, "status": "pending"}
target["metadata"].pop("error", None)
prepare_signature = _prepare_signature(files)
_set_pending_prepare_signature(data, prepare_signature)
write_library(slug, data)
job_id = await _ensure_prepare_job(slug)
return {"job_id": job_id, "library": library_payload(data)}
@router.post("/libraries/{slug}/jobs/build")
async def build_library(slug: str):
data = read_library(slug)
payload = library_payload(data)
if not payload["states"].get("has_files"):
raise HTTPException(status_code=400, detail="Add files before building a library.")
lock = LIB_LOCKS.setdefault(slug, asyncio.Lock())
async with lock:
if _has_active_job(slug):
raise HTTPException(status_code=409, detail="This library already has an active job.")
job_id = _start_job(
slug,
"build",
root=stage_dir(slug),
out=_collect_library_paths(slug)["corpus"],
emit="per-file",
lang_detect=False,
stage_signature=payload.get("corpus_signature"),
)
return {"job_id": job_id}
@router.post("/libraries/{slug}/jobs/enrich")
async def enrich_library(slug: str):
data = read_library(slug)
payload = library_payload(data)
if not payload["states"].get("has_corpus"):
raise HTTPException(status_code=400, detail="Build the corpus before enrichment.")
lock = LIB_LOCKS.setdefault(slug, asyncio.Lock())
async with lock:
if _has_active_job(slug):
raise HTTPException(status_code=409, detail="This library already has an active job.")
job_id = _start_job(slug, "enrich", stage_signature=payload.get("prepare_signature"))
return {"job_id": job_id}
@router.post("/libraries/{slug}/jobs/embed")
async def embed_library(slug: str, req: EmbedLibraryRequest):
data = read_library(slug)
payload = library_payload(data)
pipeline = _pipeline_meta(data)
paths = _collect_library_paths(slug)
if not payload["states"].get("has_corpus"):
raise HTTPException(status_code=400, detail="Build the corpus before indexing.")
2026-03-20 12:00:44 +01:00
embed_model = req.embed_model or _default_embed_model() or pipeline.get("embed_model") or DEFAULT_EMBED_MODEL
lock = LIB_LOCKS.setdefault(slug, asyncio.Lock())
async with lock:
if _has_active_job(slug):
raise HTTPException(status_code=409, detail="This library already has an active job.")
job_id = _start_job(
slug,
"embed",
raw=paths["corpus"],
enhanced=None,
shadow=paths["shadow"] if paths["shadow"].exists() else None,
out_dir=paths["indexes"],
embed_model=embed_model,
2026-03-20 12:00:44 +01:00
ollama=_resolve_ollama_url(req.ollama),
target_chars=req.target_chars,
overlap_chars=req.overlap_chars,
concurrency=req.concurrency,
stage_signature=payload.get("prepare_signature"),
)
return {"job_id": job_id}
@router.post("/libraries/{slug}/jobs/prepare")
async def prepare_library(slug: str):
data = read_library(slug)
payload = library_payload(data)
if not payload["states"].get("has_files"):
raise HTTPException(status_code=400, detail="Add files before preparing Knowledge.")
lock = LIB_LOCKS.setdefault(slug, asyncio.Lock())
async with lock:
if _has_active_job(slug):
raise HTTPException(status_code=409, detail="This library already has an active job.")
job_id = _start_job(slug, "prepare")
return {"job_id": job_id}
@router.get("/jobs")
def list_jobs(slug: Optional[str] = None):
jobs = [_job_public(job) for job in JOBS.values() if slug is None or job["slug"] == slug]
jobs.sort(key=lambda item: item.get("created_at", ""), reverse=True)
return {"jobs": jobs}
@router.get("/jobs/{job_id}")
def get_job(job_id: str):
job = JOBS.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return _job_public(job)
@router.post("/libraries/{slug}/context")
def library_context(slug: str, req: LibraryContextRequest):
payload = library_payload(read_library(slug))
pipeline = payload.get("pipeline") or {}
paths = _collect_library_paths(slug)
if not payload["states"].get("is_indexed"):
raise HTTPException(status_code=400, detail="Prepare the library before using it in chat.")
2026-03-20 12:00:44 +01:00
embed_model = req.embed_model or pipeline.get("embed_model") or _default_embed_model() or DEFAULT_EMBED_MODEL
try:
run_query = _load_pipeline_fn("unified_rag", "run_query")
query_lock = QUERY_LOCKS.setdefault(slug, threading.Lock())
with query_lock:
result = run_query(
shadow_index=paths["shadow_index"],
shadow_store=paths["shadow_store"],
content_index=paths["content_index"],
content_store=paths["content_store"],
query=req.prompt,
answer=False,
ollama=_resolve_ollama_url(req.ollama),
embed_model=embed_model,
gen_model=req.gen_model,
no_rerank=True,
k=max(1, req.top_k),
)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Local retrieval failed: {type(exc).__name__}: {exc}") from exc
context = _build_local_context(req.prompt, result, top_k=req.top_k)
return {
"context_block": context["context_block"],
"sources": context["sources"],
"result": result,
}