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

2762 lines
105 KiB
Python

"""
Knowledge Base API Router
=========================
Handles knowledge base CRUD operations, file uploads, and initialization.
"""
import asyncio
from datetime import datetime
import json
import logging
import mimetypes
import os
from pathlib import Path
import re
import shutil
import traceback
from uuid import uuid4
from fastapi import (
APIRouter,
BackgroundTasks,
File,
Form,
HTTPException,
UploadFile,
WebSocket,
WebSocketDisconnect,
)
from fastapi.responses import FileResponse, PlainTextResponse, StreamingResponse
from pydantic import BaseModel
from deeptutor.api.utils.progress_broadcaster import ProgressBroadcaster
from deeptutor.api.utils.task_id_manager import TaskIDManager
from deeptutor.api.utils.task_log_stream import capture_task_logs, get_task_stream_manager
from deeptutor.knowledge.add_documents import DocumentAdder, remove_raw_document
from deeptutor.knowledge.initializer import KnowledgeBaseInitializer
from deeptutor.knowledge.kb_types import is_connected_kb
from deeptutor.knowledge.manager import KnowledgeBaseManager
from deeptutor.knowledge.naming import validate_knowledge_base_name
from deeptutor.knowledge.progress_tracker import ProgressStage, ProgressTracker
from deeptutor.multi_user.context import get_current_user
from deeptutor.multi_user.knowledge_access import (
assert_writable,
current_kb_base_dir,
current_kb_manager,
manager_for_resource,
resolve_kb,
)
from deeptutor.multi_user.knowledge_access import (
list_visible_knowledge_bases as list_visible_kb_access,
)
from deeptutor.services.config import PROJECT_ROOT, load_config_with_main
from deeptutor.services.rag.factory import (
DEFAULT_PROVIDER,
GRAPHRAG_PROVIDER,
LIGHTRAG_PROVIDER,
PAGEINDEX_PROVIDER,
normalize_provider_name,
provider_uses_embedding_versions,
)
from deeptutor.services.rag.file_routing import FileTypeRouter
from deeptutor.services.rag.linked_kb import (
LINKABLE_PROVIDERS,
assert_path_allowed,
probe_linked_folder,
)
from deeptutor.utils.document_extractor import (
MAX_EXTRACTED_CHARS_PER_DOC,
DocumentExtractionError,
extract_text_from_path,
)
from deeptutor.utils.document_validator import DocumentValidator
from deeptutor.utils.error_utils import format_exception_message
# Initialize logger with config
config = load_config_with_main("main.yaml", PROJECT_ROOT)
log_dir = config.get("paths", {}).get("user_log_dir") or config.get("logging", {}).get("log_dir")
logger = logging.getLogger(__name__)
router = APIRouter()
# Constants for byte conversions
BYTES_PER_GB = 1024**3
BYTES_PER_MB = 1024**2
def format_bytes_human_readable(size_bytes: int) -> str:
"""Format bytes into human-readable string (GB, MB, or bytes)."""
if size_bytes >= BYTES_PER_GB:
return f"{size_bytes / BYTES_PER_GB:.1f} GB"
elif size_bytes >= BYTES_PER_MB:
return f"{size_bytes / BYTES_PER_MB:.1f} MB"
else:
return f"{size_bytes} bytes"
_kb_base_dir = PROJECT_ROOT / "data" / "knowledge_bases"
DEFAULT_KB_ALIASES = {"", "default", "current", "selected", "默认", "默认知识库", "当前知识库"}
# Lazy initialization
kb_manager = None
def get_kb_manager():
"""Get KnowledgeBaseManager instance (lazy init)"""
if kb_manager is not None:
return kb_manager
return current_kb_manager()
def _overridden_kb_manager() -> KnowledgeBaseManager | None:
"""Return the legacy/test manager when the route-level getter is patched.
Production multi-user access control goes through ``assert_writable`` and
``resolve_kb``. Older tests and single-module integrations patch
``get_kb_manager`` directly, so we keep that seam without weakening the
normal write guard.
"""
manager = get_kb_manager()
if kb_manager is not None or manager is not current_kb_manager():
return manager
return None
def _current_kb_base_dir() -> Path:
manager = _overridden_kb_manager()
if manager is not None:
return Path(manager.base_dir)
return current_kb_base_dir()
def _writable_kb(kb_name: str) -> tuple[KnowledgeBaseManager, str, Path]:
manager = _overridden_kb_manager()
if manager is not None:
resolved_name = _resolve_registered_kb_name(manager, kb_name)
return manager, resolved_name, Path(manager.base_dir)
resource = assert_writable(kb_name)
return manager_for_resource(resource), resource.name, resource.base_dir
class KnowledgeBaseInfo(BaseModel):
id: str | None = None
name: str
is_default: bool
statistics: dict
metadata: dict | None = None
path: str | None = None
status: str | None = None
progress: dict | None = None
source: str | None = None
assigned: bool = False
read_only: bool = False
provenance_label: str | None = None
available: bool = True
class LinkFolderRequest(BaseModel):
"""Request model for linking a local folder to a KB."""
folder_path: str
class LinkedFolderInfo(BaseModel):
"""Response model for linked folder information."""
id: str
path: str
added_at: str
file_count: int
class SupportedFileTypesInfo(BaseModel):
"""Upload constraints exposed to the web client."""
extensions: list[str]
accept: str
max_file_size_bytes: int
IMAGE_ACCEPT_MIME_TYPES = {
".bmp": "image/bmp",
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".tif": "image/tiff",
".tiff": "image/tiff",
".webp": "image/webp",
}
def _build_unique_task_id(task_type: str, task_key_prefix: str) -> str:
task_manager = TaskIDManager.get_instance()
task_key = f"{task_key_prefix}_{datetime.now().isoformat()}_{uuid4().hex[:8]}"
return task_manager.generate_task_id(task_type, task_key)
def _save_zip_archive(
file: UploadFile,
sanitized_filename: str,
target_dir: Path,
allowed_extensions: set[str] | None,
) -> list[Path]:
"""Safely expand an uploaded ``.zip`` into ``target_dir``.
The archive itself is never persisted; each member is validated and
extracted via :func:`safe_extract_zip` (Zip Slip / zip-bomb / extension
guards). Returns the list of written file paths.
"""
import tempfile
import zipfile
from deeptutor.utils.archive_extractor import ArchiveTooLargeError, safe_extract_zip
file.file.seek(0)
max_size = DocumentValidator.MAX_FILE_SIZE
tmp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
tmp_path = Path(tmp.name)
written = 0
for chunk in iter(lambda: file.file.read(8192), b""):
written += len(chunk)
if written > max_size:
raise HTTPException(
status_code=400,
detail=(
f"Archive '{sanitized_filename}' exceeds maximum size limit of "
f"{format_bytes_human_readable(max_size)}"
),
)
tmp.write(chunk)
try:
result = safe_extract_zip(
tmp_path, target_dir, allowed_extensions=allowed_extensions or set()
)
except ArchiveTooLargeError as exc:
raise HTTPException(
status_code=400,
detail=f"Rejected archive '{sanitized_filename}': {exc}",
) from exc
except zipfile.BadZipFile as exc:
raise HTTPException(
status_code=400,
detail=f"'{sanitized_filename}' is not a valid zip archive.",
) from exc
if not result.extracted:
raise HTTPException(
status_code=400,
detail=f"Archive '{sanitized_filename}' contained no supported files.",
)
return result.extracted
finally:
if tmp_path is not None:
tmp_path.unlink(missing_ok=True)
# Folder organization is purely a human-facing layout: folders are real
# subdirectories under ``raw/`` (no manifest, no retrieval effect). These
# helpers keep user-supplied relative paths safe before they touch the FS.
_BAD_PATH_CHARS = re.compile(r'[\\:*?"<>|\x00-\x1f]')
def _sanitize_path_segment(segment: str) -> str:
"""Sanitize a single folder/file path segment for safe FS use."""
cleaned = _BAD_PATH_CHARS.sub("", segment).strip().strip(".")
return cleaned[:128]
def _sanitize_rel_subdir(rel_path: str | None) -> str:
"""Return a safe POSIX relative subdir (folders only, no traversal).
A leading/trailing or interior ``..``/absolute marker raises 400 so a
crafted directory upload can never escape ``raw/``.
"""
if not rel_path:
return ""
parts: list[str] = []
for raw_seg in str(rel_path).replace("\\", "/").split("/"):
seg = raw_seg.strip()
if seg in ("", "."):
continue
if seg == "..":
raise HTTPException(status_code=400, detail="Invalid folder path")
safe = _sanitize_path_segment(seg)
if safe:
parts.append(safe)
return "/".join(parts)
def _safe_join_raw(raw_dir: Path, rel_path: str) -> Path:
"""Resolve ``rel_path`` under ``raw_dir``, rejecting traversal."""
target = (raw_dir / rel_path).resolve()
try:
target.relative_to(raw_dir.resolve())
except ValueError as exc:
raise HTTPException(status_code=403, detail="Access denied") from exc
return target
def _save_uploaded_files(
files: list[UploadFile],
target_dir: Path,
allowed_extensions: set[str] | None = None,
kb_name: str | None = None,
rel_paths: list[str] | None = None,
) -> tuple[list[str], list[str]]:
"""
Save uploaded files to the local raw/ directory.
When PocketBase is enabled and ``kb_name`` is supplied, each file is also
uploaded to the PocketBase knowledge_bases record as a file attachment
(best-effort — local write is always the primary path).
"""
uploaded_files: list[str] = []
uploaded_file_paths: list[str] = []
written_file_paths: list[Path] = []
from deeptutor.services.pocketbase_client import is_pocketbase_enabled
_pb_sync = is_pocketbase_enabled() and bool(kb_name)
try:
for idx, file in enumerate(files):
file_path = None
original_filename = file.filename or "upload"
try:
sanitized_filename = DocumentValidator.validate_upload_safety(
original_filename,
_get_upload_file_size(file),
allowed_extensions=allowed_extensions,
)
file.filename = sanitized_filename
# Directory uploads carry a relative path (folder/sub/file); the
# folder portion is preserved under raw/ so nested structure is
# kept verbatim. Single-file uploads have no rel path → root.
rel = (
rel_paths[idx].replace("\\", "/")
if rel_paths and idx < len(rel_paths) and rel_paths[idx]
else ""
)
subdir = _sanitize_rel_subdir(rel.rsplit("/", 1)[0]) if "/" in rel else ""
dest_dir = target_dir / subdir if subdir else target_dir
if subdir:
dest_dir.mkdir(parents=True, exist_ok=True)
rel_name = f"{subdir}/{sanitized_filename}" if subdir else sanitized_filename
if Path(sanitized_filename).suffix.lower() == ".zip":
# Expand the archive in place; register each extracted
# member instead of the zip itself.
for dest in _save_zip_archive(
file, sanitized_filename, dest_dir, allowed_extensions
):
written_file_paths.append(dest)
uploaded_files.append(dest.relative_to(target_dir).as_posix())
uploaded_file_paths.append(str(dest))
if _pb_sync and kb_name:
try:
_upload_file_to_pb(kb_name, dest.name, dest)
except Exception as pb_exc:
logger.debug(
"PocketBase file upload failed for '%s': %s",
dest.name,
pb_exc,
)
continue
file_path = dest_dir / sanitized_filename
max_size = DocumentValidator.MAX_FILE_SIZE
written_bytes = 0
file.file.seek(0)
with open(file_path, "wb") as buffer:
for chunk in iter(lambda: file.file.read(8192), b""):
written_bytes += len(chunk)
if written_bytes > max_size:
size_str = format_bytes_human_readable(max_size)
raise HTTPException(
status_code=400,
detail=(
f"File '{sanitized_filename}' exceeds maximum size "
f"limit of {size_str}"
),
)
buffer.write(chunk)
DocumentValidator.validate_upload_safety(
sanitized_filename, written_bytes, allowed_extensions=allowed_extensions
)
written_file_paths.append(file_path)
uploaded_files.append(rel_name)
uploaded_file_paths.append(str(file_path))
# Mirror file to PocketBase when enabled (best-effort, non-blocking).
if _pb_sync and kb_name:
try:
_upload_file_to_pb(kb_name, sanitized_filename, file_path)
except Exception as pb_exc:
logger.debug(
"PocketBase file upload failed for '%s': %s",
sanitized_filename,
pb_exc,
)
except Exception as e:
if file_path and file_path.exists():
try:
os.unlink(file_path)
except OSError:
pass
error_message = f"Validation failed for file '{original_filename}': {format_exception_message(e)}"
logger.error(error_message, exc_info=True)
raise HTTPException(status_code=400, detail=error_message) from e
except Exception:
for written_path in written_file_paths:
if written_path.exists():
try:
os.unlink(written_path)
except OSError:
pass
raise
return uploaded_files, uploaded_file_paths
def _get_upload_file_size(file: UploadFile) -> int | None:
"""Best-effort byte size detection without consuming the uploaded stream."""
try:
current_position = file.file.tell()
file.file.seek(0, os.SEEK_END)
size = file.file.tell()
file.file.seek(current_position)
return size
except Exception:
return None
def _validate_upload_batch(
files: list[UploadFile],
allowed_extensions: set[str] | None = None,
rel_paths: list[str] | None = None,
) -> list[dict[str, int | str | None]]:
"""Validate upload metadata before mutating KB state or writing any files."""
validated: list[dict[str, int | str | None]] = []
seen_names: set[str] = set()
for idx, file in enumerate(files):
original_filename = file.filename or "upload"
size_bytes = _get_upload_file_size(file)
try:
sanitized_filename = DocumentValidator.validate_upload_safety(
original_filename,
size_bytes,
allowed_extensions=allowed_extensions,
)
except Exception as e:
error_message = (
f"Validation failed for file '{original_filename}': {format_exception_message(e)}"
)
raise HTTPException(status_code=400, detail=error_message) from e
rel = (
rel_paths[idx].replace("\\", "/")
if rel_paths and idx < len(rel_paths) and rel_paths[idx]
else ""
)
subdir = _sanitize_rel_subdir(rel.rsplit("/", 1)[0]) if "/" in rel else ""
duplicate_key = f"{subdir}/{sanitized_filename}" if subdir else sanitized_filename
if duplicate_key in seen_names:
raise HTTPException(
status_code=400,
detail=(
f"Duplicate filename after sanitization: '{duplicate_key}'. "
"Rename one of the files and try again."
),
)
seen_names.add(duplicate_key)
validated.append(
{
"original_filename": original_filename,
"sanitized_filename": sanitized_filename,
"path": duplicate_key,
"size_bytes": size_bytes,
}
)
return validated
def _upload_file_to_pb(kb_name: str, filename: str, file_path: Path) -> None:
"""Upload a single file to the PocketBase knowledge_bases record."""
try:
from deeptutor.services.pocketbase_client import get_pb_client
pb = get_pb_client()
records = pb.collection("knowledge_bases").get_full_list(
query_params={"filter": f'kb_name="{kb_name}"'}
)
if not records:
logger.debug(f"PocketBase KB record not found for '{kb_name}', skipping file upload")
return
with open(file_path, "rb") as fh:
pb.collection("knowledge_bases").update(
records[0].id,
body={"kb_name": kb_name},
files={"raw_files": (filename, fh)},
)
logger.debug(f"Uploaded '{filename}' to PocketBase KB '{kb_name}'")
except Exception as exc:
logger.debug(f"_upload_file_to_pb failed: {exc}")
def _task_log(task_id: str, message: str, level: str = "info") -> None:
manager = get_task_stream_manager()
manager.ensure_task(task_id)
manager.emit_log(task_id, message)
log_method = getattr(logger, level, None)
if callable(log_method):
log_method(f"[{task_id}] {message}")
else:
logger.info(f"[{task_id}] {message}")
def _validate_registered_provider(raw_provider: str | None) -> str:
"""Resolve a requested provider to a known engine.
Empty / legacy / unknown strings coerce to the default (so a stale config or
a removed engine never selects a missing pipeline). Returning the real,
canonical provider is what makes the per-KB lock meaningful: the upload route
compares the requested provider against the KB's bound provider, so asking to
add to a ``pageindex`` KB with ``llamaindex`` (or vice versa) is rejected.
"""
return normalize_provider_name(raw_provider)
def _assert_provider_ready(provider: str) -> None:
"""Block creating/using a KB whose engine isn't ready.
PageIndex needs an API key; GraphRAG needs the optional package installed.
"""
if provider == PAGEINDEX_PROVIDER:
from deeptutor.services.rag.pipelines.pageindex.config import is_pageindex_configured
if not is_pageindex_configured():
raise HTTPException(
status_code=400,
detail=(
"PageIndex API key is not configured. Add it under "
"Knowledge → RAG pipeline settings before creating a PageIndex "
"knowledge base."
),
)
if provider == GRAPHRAG_PROVIDER:
from deeptutor.services.rag.pipelines.graphrag.config import is_graphrag_available
if not is_graphrag_available():
raise HTTPException(
status_code=400,
detail=(
"GraphRAG is not installed. Run "
"`pip install 'deeptutor[graphrag]'` on the server before "
"creating a GraphRAG knowledge base."
),
)
if provider == LIGHTRAG_PROVIDER:
from deeptutor.services.rag.pipelines.lightrag.config import is_lightrag_available
if not is_lightrag_available():
raise HTTPException(
status_code=400,
detail=(
"LightRAG is not installed. Run "
"`pip install 'deeptutor[rag-lightrag]'` on the server before "
"creating a LightRAG knowledge base."
),
)
def _enforce_provider_formats(provider: str, files: list[UploadFile]) -> None:
"""PageIndex ingests PDF/Markdown only — reject other formats up front."""
if provider != PAGEINDEX_PROVIDER:
return
from deeptutor.services.rag.pipelines.pageindex.pipeline import SUPPORTED_EXTENSIONS
unsupported = [
f.filename
for f in files
if f.filename
and not f.filename.lower().endswith(".zip")
and Path(f.filename).suffix.lower() not in SUPPORTED_EXTENSIONS
]
if unsupported:
raise HTTPException(
status_code=400,
detail=(
"PageIndex knowledge bases accept PDF and Markdown only. "
f"Unsupported: {', '.join(unsupported[:5])}."
),
)
def _resolve_registered_kb_name(manager: KnowledgeBaseManager, kb_name: str | None) -> str:
"""Resolve route-level default aliases to the configured default KB."""
requested = str(kb_name or "").strip()
kb_names = manager.list_knowledge_bases()
if requested and requested in kb_names:
return requested
if requested.lower() in DEFAULT_KB_ALIASES:
default_kb = manager.get_default()
if default_kb and default_kb in kb_names:
return default_kb
raise HTTPException(status_code=404, detail="No default knowledge base is configured")
raise HTTPException(status_code=404, detail=f"Knowledge base '{requested}' not found")
def _load_kb_entry_or_404(manager: KnowledgeBaseManager, kb_name: str) -> dict:
manager.config = manager._load_config()
kb_entry = manager.config.get("knowledge_bases", {}).get(kb_name)
if kb_entry is None:
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
return kb_entry
def _assert_not_connected_kb(kb_name: str, kb_entry: dict) -> None:
"""Block writes to connected KBs (Obsidian vaults, linked indexes).
They are read-only pointers to the user's external files — we never write
into or re-index them.
"""
if is_connected_kb(kb_entry):
raise HTTPException(
status_code=409,
detail=(
f"Knowledge base '{kb_name}' is connected to an external folder and is "
"read-only. Uploads and re-indexing are not available for it."
),
)
def _assert_kb_writable_or_409(kb_name: str, kb_entry: dict) -> None:
_assert_not_connected_kb(kb_name, kb_entry)
if bool(kb_entry.get("needs_reindex", False)):
raise HTTPException(
status_code=409,
detail=(
f"Knowledge base '{kb_name}' uses legacy index format and needs reindex "
"before accepting incremental uploads."
),
)
def _matching_index_is_valid(kb_name: str, matching_version: dict | None) -> bool:
"""Return whether a matching active index can safely satisfy retrieval."""
if not matching_version:
return False
try:
from deeptutor.services.rag.index_probe import inspect_provider_version
from deeptutor.services.rag.pipelines.llamaindex.storage import (
validate_storage_embeddings,
)
probe = inspect_provider_version(matching_version, DEFAULT_PROVIDER)
if not probe.ready:
logger.warning(
"Matching index for KB '%s' is not provider-ready; forcing re-index: %s",
kb_name,
probe.failure_summary or probe.diagnostics,
)
return False
validate_storage_embeddings(Path(str(matching_version["storage_path"])))
return True
except Exception as exc:
logger.warning(
"Matching index for KB '%s' is invalid; forcing re-index: %s",
kb_name,
exc,
)
return False
async def run_initialization_task(initializer: KnowledgeBaseInitializer, task_id: str):
"""Background task for knowledge base initialization"""
task_manager = TaskIDManager.get_instance()
task_stream_manager = get_task_stream_manager()
task_stream_manager.ensure_task(task_id)
with capture_task_logs(task_id):
try:
if not initializer.progress_tracker:
initializer.progress_tracker = ProgressTracker(
initializer.kb_name, initializer.base_dir
)
initializer.progress_tracker.task_id = task_id
_task_log(task_id, f"Initializing knowledge base '{initializer.kb_name}'")
await initializer.process_documents()
_task_log(task_id, "Document processing complete")
_task_log(task_id, "Finalizing initialization")
indexed_count = len(
FileTypeRouter.collect_supported_files(initializer.raw_dir, recursive=True)
)
initializer.progress_tracker.update(
ProgressStage.COMPLETED,
"Knowledge base initialization complete!",
current=1,
total=1,
indexed_count=indexed_count,
index_changed=True,
index_action="create",
)
manager = get_kb_manager()
manager.update_kb_status(
name=initializer.kb_name,
status="ready",
progress={
"stage": "completed",
"message": "Knowledge base initialization complete!",
"percent": 100,
"current": 1,
"total": 1,
"task_id": task_id,
"timestamp": datetime.now().isoformat(),
"indexed_count": indexed_count,
"index_changed": True,
"index_action": "create",
},
)
_task_log(
task_id, f"Knowledge base '{initializer.kb_name}' initialized", level="success"
)
task_manager.update_task_status(task_id, "completed")
task_stream_manager.emit_complete(
task_id, f"Knowledge base '{initializer.kb_name}' initialization complete"
)
except Exception as e:
import traceback as _tb
error_msg = str(e)
trace = _tb.format_exc()
_task_log(task_id, f"Initialization failed: {error_msg}", level="error")
_task_log(task_id, f"Stack trace:\n{trace}", level="error")
task_manager.update_task_status(task_id, "error", error=error_msg)
manager = get_kb_manager()
manager.update_kb_status(
name=initializer.kb_name,
status="error",
progress={
"stage": "error",
"message": f"Initialization failed: {error_msg}",
"percent": 0,
"error": error_msg,
"task_id": task_id,
"timestamp": datetime.now().isoformat(),
},
)
if initializer.progress_tracker:
initializer.progress_tracker.update(
ProgressStage.ERROR, f"Initialization failed: {error_msg}", error=error_msg
)
task_stream_manager.emit_failed(task_id, error_msg, details=trace)
async def run_upload_processing_task(
kb_name: str,
base_dir: str,
uploaded_file_paths: list[str],
task_id: str,
rag_provider: str = None,
folder_id: str = None,
):
"""Background task for processing uploaded files.
Args:
kb_name: Knowledge base name
base_dir: Base directory for knowledge bases
uploaded_file_paths: List of file paths to process
rag_provider: RAG provider already matched against the KB binding
folder_id: Optional folder ID for sync state update
"""
task_manager = TaskIDManager.get_instance()
task_stream_manager = get_task_stream_manager()
task_stream_manager.ensure_task(task_id)
progress_tracker = ProgressTracker(kb_name, Path(base_dir))
progress_tracker.task_id = task_id
with capture_task_logs(task_id):
try:
_task_log(task_id, f"Processing {len(uploaded_file_paths)} file(s) for KB '{kb_name}'")
progress_tracker.update(
ProgressStage.PROCESSING_DOCUMENTS,
f"Processing {len(uploaded_file_paths)} files...",
current=0,
total=len(uploaded_file_paths),
)
adder = DocumentAdder(
kb_name=kb_name,
base_dir=base_dir,
progress_tracker=progress_tracker,
rag_provider=rag_provider,
)
staged_files = adder.add_documents(uploaded_file_paths, allow_duplicates=False)
_task_log(task_id, f"Staged {len(staged_files)} new file(s)")
if not staged_files:
_task_log(task_id, "No new files to process (all duplicates or invalid)")
progress_tracker.update(
ProgressStage.COMPLETED,
"No new files to process (all duplicates or invalid)",
current=0,
total=0,
)
task_manager.update_task_status(task_id, "completed")
task_stream_manager.emit_complete(
task_id, "No new files to process (all duplicates or invalid)"
)
return
index_result = await adder.process_new_documents(staged_files)
processed_files = index_result.processed_files
_task_log(task_id, f"Indexed {index_result.processed_count} file(s)")
if index_result.has_failures:
failure_summary = index_result.failure_summary()
error_msg = (
f"Indexed {index_result.processed_count}/{len(staged_files)} file(s); "
f"{index_result.failed_count} failed: {failure_summary}"
)
_task_log(task_id, error_msg, level="error")
for failure in index_result.failures:
_task_log(
task_id,
f"Failed to index {failure.file_path.name}: {failure.error}",
level="error",
)
progress_tracker.update(
ProgressStage.ERROR,
f"Processing failed: {error_msg}",
current=index_result.processed_count,
total=len(staged_files),
error=error_msg,
indexed_count=index_result.processed_count,
index_changed=index_result.processed_count > 0,
index_action="upload",
)
task_manager.update_task_status(task_id, "error", error=error_msg)
task_stream_manager.emit_failed(
task_id,
error_msg,
details="\n".join(
f"{failure.file_path}: {failure.error}" for failure in index_result.failures
),
)
return
adder.update_metadata(index_result.processed_count)
if folder_id and processed_files:
try:
manager = get_kb_manager()
manager.update_folder_sync_state(
kb_name, folder_id, [str(f) for f in processed_files]
)
_task_log(task_id, f"Updated folder sync state: {folder_id}")
except Exception as sync_err:
_task_log(
task_id, f"Folder sync state update failed: {sync_err}", level="warning"
)
num_processed = index_result.processed_count
progress_tracker.update(
ProgressStage.COMPLETED,
f"Successfully processed {num_processed} files!",
current=num_processed,
total=num_processed,
indexed_count=num_processed,
index_changed=num_processed > 0,
index_action="upload",
)
_task_log(
task_id, f"Processed {num_processed} file(s) for '{kb_name}'", level="success"
)
task_manager.update_task_status(task_id, "completed")
task_stream_manager.emit_complete(
task_id, f"Successfully processed {num_processed} files for '{kb_name}'"
)
except Exception as e:
import traceback as _tb
error_msg = f"Upload processing failed (KB '{kb_name}'): {e}"
trace = _tb.format_exc()
_task_log(task_id, error_msg, level="error")
_task_log(task_id, f"Stack trace:\n{trace}", level="error")
task_manager.update_task_status(task_id, "error", error=error_msg)
progress_tracker.update(
ProgressStage.ERROR, f"Processing failed: {error_msg}", error=error_msg
)
task_stream_manager.emit_failed(task_id, error_msg, details=trace)
@router.get("/health")
async def health_check():
"""Health check endpoint"""
try:
manager = get_kb_manager()
config_exists = manager.config_file.exists()
kb_count = len(manager.list_knowledge_bases())
return {
"status": "ok",
"config_file": str(manager.config_file),
"config_exists": config_exists,
"base_dir": str(manager.base_dir),
"base_dir_exists": manager.base_dir.exists(),
"knowledge_bases_count": kb_count,
}
except Exception as e:
return {"status": "error", "error": str(e), "traceback": traceback.format_exc()}
@router.get("/rag-providers")
async def get_rag_providers():
"""Get list of available RAG providers (with the active per-engine mode)."""
try:
from deeptutor.services.config import get_kb_config_service
from deeptutor.services.rag.service import RAGService
providers = RAGService.list_providers()
kb_config = get_kb_config_service()
for provider in providers:
modes = provider.get("modes") or []
if modes:
stored = kb_config.get_provider_mode(provider["id"])
if stored in modes:
provider["default_mode"] = stored
# Whether an existing index for this engine can be linked in place
# (self-contained on disk). Drives the "link existing folder" UI.
provider["linkable"] = provider.get("id") in LINKABLE_PROVIDERS
return {"providers": providers}
except Exception as e:
logger.error(f"Error getting RAG providers: {e}")
raise HTTPException(status_code=500, detail=str(e))
class ProviderModeUpdate(BaseModel):
"""Set an engine's global default retrieval mode (from its engine card)."""
mode: str
@router.put("/rag-providers/{provider}/mode")
async def set_rag_provider_mode(provider: str, payload: ProviderModeUpdate):
"""Persist the default retrieval mode for a mode-aware engine.
The mode must be one the engine supports; a KB's own ``search_mode`` still
overrides this per-KB default.
"""
from deeptutor.services.config import get_kb_config_service
from deeptutor.services.rag.service import RAGService
entry = next((p for p in RAGService.list_providers() if p["id"] == provider), None)
modes = (entry or {}).get("modes") or []
if entry is None or not modes:
raise HTTPException(status_code=404, detail=f"No retrieval modes for engine '{provider}'.")
mode = (payload.mode or "").strip().lower()
if mode not in modes:
raise HTTPException(
status_code=400,
detail=f"Invalid mode '{payload.mode}' for {provider}. Choose one of: {', '.join(modes)}.",
)
get_kb_config_service().set_provider_mode(provider, mode)
return {"provider": provider, "mode": mode}
class PageIndexConfigUpdate(BaseModel):
# Tri-state api_key: omit/None keeps the stored key, "" clears it, any other
# value replaces it — so the masked UI never round-trips the real secret.
api_key: str | None = None
api_base_url: str | None = None
def _pageindex_config_payload() -> dict:
"""PageIndex pipeline settings for the UI, with the API key redacted."""
from deeptutor.services.config import get_runtime_settings_service
settings = get_runtime_settings_service().load_pageindex()
return {
"api_base_url": settings.get("api_base_url") or "",
"api_key_set": bool(settings.get("api_key")),
"configured": bool(settings.get("api_key")),
}
@router.get("/rag-pipelines/pageindex/config")
async def get_pageindex_pipeline_config():
"""Read the PageIndex credential state (key redacted to a boolean)."""
try:
return _pageindex_config_payload()
except Exception as e:
logger.error(f"Error reading PageIndex config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/rag-pipelines/pageindex/config")
async def update_pageindex_pipeline_config(payload: PageIndexConfigUpdate):
"""Persist the PageIndex API key / base URL for this user's account."""
try:
from deeptutor.services.config import get_runtime_settings_service
from deeptutor.services.rag.pipelines.pageindex.config import DEFAULT_API_BASE_URL
service = get_runtime_settings_service()
current = service.load_pageindex(include_process_overrides=False)
api_key = current.get("api_key", "")
if payload.api_key is not None:
api_key = payload.api_key.strip()
api_base_url = current.get("api_base_url") or DEFAULT_API_BASE_URL
if payload.api_base_url is not None and payload.api_base_url.strip():
api_base_url = payload.api_base_url.strip()
service.save_pageindex({"api_key": api_key, "api_base_url": api_base_url})
return _pageindex_config_payload()
except Exception as e:
logger.error(f"Error updating PageIndex config: {e}")
raise HTTPException(status_code=500, detail=str(e))
class LlamaIndexConfigUpdate(BaseModel):
"""Partial update for the LlamaIndex engine knobs (omitted fields kept)."""
retrieval_profile: str | None = None
top_k: int | None = None
vector_top_k_multiplier: int | None = None
bm25_top_k_multiplier: int | None = None
chunk_size: int | None = None
chunk_overlap: int | None = None
@router.get("/rag-pipelines/llamaindex/config")
async def get_llamaindex_pipeline_config():
"""Read the LlamaIndex engine's retrieval + chunking knobs."""
try:
from deeptutor.services.config import get_runtime_settings_service
return get_runtime_settings_service().load_llamaindex()
except Exception as e:
logger.error(f"Error reading LlamaIndex config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/rag-pipelines/llamaindex/config")
async def update_llamaindex_pipeline_config(payload: LlamaIndexConfigUpdate):
"""Persist the LlamaIndex engine knobs.
Retrieval knobs take effect on the next query; chunk geometry only changes
how documents indexed *after* the save are split.
"""
try:
from deeptutor.services.config import get_runtime_settings_service
service = get_runtime_settings_service()
current = service.load_llamaindex(include_process_overrides=False)
# Merge only the provided fields so partial saves never wipe others.
updates = payload.model_dump(exclude_none=True)
return service.save_llamaindex({**current, **updates})
except Exception as e:
logger.error(f"Error updating LlamaIndex config: {e}")
raise HTTPException(status_code=500, detail=str(e))
class GraphRagConfigUpdate(BaseModel):
"""Partial update for GraphRAG query knobs (omitted fields kept)."""
response_type: str | None = None
community_level: int | None = None
dynamic_community_selection: bool | None = None
@router.get("/rag-pipelines/graphrag/config")
async def get_graphrag_pipeline_config():
"""Read GraphRAG's query knobs (response style, community granularity)."""
try:
from deeptutor.services.config import get_runtime_settings_service
return get_runtime_settings_service().load_graphrag()
except Exception as e:
logger.error(f"Error reading GraphRAG config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/rag-pipelines/graphrag/config")
async def update_graphrag_pipeline_config(payload: GraphRagConfigUpdate):
"""Persist GraphRAG's query knobs. Takes effect on the next query."""
try:
from deeptutor.services.config import get_runtime_settings_service
service = get_runtime_settings_service()
current = service.load_graphrag()
updates = payload.model_dump(exclude_none=True)
return service.save_graphrag({**current, **updates})
except Exception as e:
logger.error(f"Error updating GraphRAG config: {e}")
raise HTTPException(status_code=500, detail=str(e))
class LightRagConfigUpdate(BaseModel):
"""Partial update for LightRAG query knobs (omitted fields kept)."""
top_k: int | None = None
response_type: str | None = None
@router.get("/rag-pipelines/lightrag/config")
async def get_lightrag_pipeline_config():
"""Read LightRAG's query knobs (top_k, response style)."""
try:
from deeptutor.services.config import get_runtime_settings_service
return get_runtime_settings_service().load_lightrag()
except Exception as e:
logger.error(f"Error reading LightRAG config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/rag-pipelines/lightrag/config")
async def update_lightrag_pipeline_config(payload: LightRagConfigUpdate):
"""Persist LightRAG's query knobs. Takes effect on the next query."""
try:
from deeptutor.services.config import get_runtime_settings_service
service = get_runtime_settings_service()
current = service.load_lightrag()
updates = payload.model_dump(exclude_none=True)
return service.save_lightrag({**current, **updates})
except Exception as e:
logger.error(f"Error updating LightRAG config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/rag-pipelines/{provider}/preflight")
async def get_rag_pipeline_preflight(provider: str):
"""Check whether ``provider`` can run in the current environment.
Returns ``{ok, checks:[{key,label,ok,detail,optional}]}`` — package
install, API key, and active model requirements per engine.
"""
try:
from deeptutor.services.rag.preflight import engine_preflight
return engine_preflight(provider)
except Exception as e:
logger.error(f"Error running preflight for '{provider}': {e}")
raise HTTPException(status_code=500, detail=str(e))
# Model kinds an engine page is allowed to read/switch. ``vision`` is not a
# catalog service (it rides on the active chat model), so it is intentionally
# excluded here.
_ENGINE_MODEL_KINDS = ("llm", "embedding")
def _model_options_payload(kinds: list[str]) -> dict:
"""Secret-free model options per kind for the engine page picker.
Exposes only ids / display labels / dimensions — never provider URLs or
API keys (those stay behind the admin-only catalog endpoint).
"""
from deeptutor.services.config import get_model_catalog_service
catalog = get_model_catalog_service().load()
services = catalog.get("services", {})
out: dict = {}
for kind in kinds:
svc = services.get(kind) or {}
options = []
for profile in svc.get("profiles", []) or []:
pid = profile.get("id")
pname = profile.get("name") or pid
for model in profile.get("models", []) or []:
detail = ""
if kind == "embedding" and model.get("dimension"):
detail = f"{model.get('dimension')}d"
options.append(
{
"profile_id": pid,
"profile_name": pname,
"model_id": model.get("id"),
"label": model.get("name") or model.get("model") or model.get("id"),
"model": model.get("model") or "",
"detail": detail,
}
)
out[kind] = {
"active": {
"profile_id": svc.get("active_profile_id"),
"model_id": svc.get("active_model_id"),
},
"options": options,
}
return out
@router.get("/rag-pipelines/model-options")
async def get_rag_model_options(kinds: str = "llm,embedding"):
"""List configured models (secret-free) for the requested model kinds."""
try:
requested = [
k.strip() for k in kinds.split(",") if k.strip() in _ENGINE_MODEL_KINDS
] or list(_ENGINE_MODEL_KINDS)
return _model_options_payload(requested)
except Exception as e:
logger.error(f"Error reading model options: {e}")
raise HTTPException(status_code=500, detail=str(e))
class ActiveModelUpdate(BaseModel):
"""Switch the globally-active model for a kind (llm / embedding)."""
kind: str
profile_id: str
model_id: str
@router.put("/rag-pipelines/active-model")
async def set_rag_active_model(payload: ActiveModelUpdate):
"""Set the active model for an engine's required kind, applied immediately.
This is the same active selection the model catalog manages; switching it
here affects every engine that uses that kind (the active model is global).
"""
if payload.kind not in _ENGINE_MODEL_KINDS:
raise HTTPException(
status_code=400,
detail=f"Unsupported model kind '{payload.kind}'. Choose one of: {', '.join(_ENGINE_MODEL_KINDS)}.",
)
try:
from deeptutor.services.config import get_model_catalog_service
service = get_model_catalog_service()
catalog = service.load()
svc = (catalog.get("services") or {}).get(payload.kind)
if not svc:
raise HTTPException(status_code=404, detail=f"No '{payload.kind}' models configured.")
profile = next(
(p for p in svc.get("profiles", []) if p.get("id") == payload.profile_id), None
)
if profile is None:
raise HTTPException(status_code=400, detail="Unknown profile for this kind.")
if not any(m.get("id") == payload.model_id for m in profile.get("models", [])):
raise HTTPException(status_code=400, detail="Unknown model for this profile.")
svc["active_profile_id"] = payload.profile_id
svc["active_model_id"] = payload.model_id
service.apply(catalog)
return _model_options_payload([payload.kind])[payload.kind]
except HTTPException:
raise
except Exception as e:
logger.error(f"Error setting active model: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/supported-file-types", response_model=SupportedFileTypesInfo)
async def get_supported_file_types():
"""Return the current upload policy so the web client stays in sync."""
extensions = sorted(FileTypeRouter.get_supported_extensions())
accept_items = extensions + [
mime
for extension, mime in sorted(IMAGE_ACCEPT_MIME_TYPES.items())
if extension in FileTypeRouter.IMAGE_EXTENSIONS
]
return SupportedFileTypesInfo(
extensions=extensions,
accept=",".join(dict.fromkeys(accept_items)),
max_file_size_bytes=DocumentValidator.MAX_FILE_SIZE,
)
@router.get("/configs")
async def get_all_kb_configs():
"""Get all knowledge base configurations from centralized config file."""
try:
from deeptutor.services.config import get_kb_config_service
service = get_kb_config_service()
return service.get_all_configs()
except Exception as e:
logger.error(f"Error getting KB configs: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{kb_name}/config")
async def get_kb_config(kb_name: str):
"""Get configuration for a specific knowledge base."""
try:
from deeptutor.services.config import get_kb_config_service
service = get_kb_config_service()
config = service.get_kb_config(kb_name)
return {"kb_name": kb_name, "config": config}
except Exception as e:
logger.error(f"Error getting config for KB '{kb_name}': {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{kb_name}/config")
async def update_kb_config(kb_name: str, config: dict):
"""Update configuration for a specific knowledge base."""
try:
from deeptutor.services.config import get_kb_config_service
from deeptutor.services.rag.index_probe import has_ready_provider_index
config = dict(config or {})
if "rag_provider" in config:
requested_provider = _validate_registered_provider(config.get("rag_provider"))
service = get_kb_config_service()
current_config = service.get_kb_config(kb_name)
current_provider = _validate_registered_provider(
current_config.get("rag_provider") or DEFAULT_PROVIDER
)
if requested_provider != current_provider:
kb_dir = _current_kb_base_dir() / kb_name
if kb_dir.exists() and has_ready_provider_index(kb_dir, current_provider):
raise HTTPException(
status_code=409,
detail=(
f"Knowledge base '{kb_name}' already has a ready "
f"{current_provider} index. Provider changes require "
"an explicit re-index/migration instead of a silent config edit."
),
)
config["needs_reindex"] = True
config.setdefault("status", "needs_reindex")
config["progress"] = {
"stage": "needs_reindex",
"message": (
f"Provider changed from {current_provider} to {requested_provider}; "
"re-index this knowledge base before use."
),
"percent": 0,
"timestamp": datetime.now().isoformat(),
}
config["rag_provider"] = requested_provider
else:
service = get_kb_config_service()
service.set_kb_config(kb_name, config)
return {"status": "success", "kb_name": kb_name, "config": service.get_kb_config(kb_name)}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating config for KB '{kb_name}': {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/configs/sync")
async def sync_configs_from_metadata():
"""Sync all KB configurations from their metadata.json files to centralized config."""
try:
from deeptutor.services.config import get_kb_config_service
service = get_kb_config_service()
service.sync_all_from_metadata(_current_kb_base_dir())
return {"status": "success", "message": "Configurations synced from metadata files"}
except Exception as e:
logger.error(f"Error syncing configs: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/default")
async def get_default_kb():
"""Get the default knowledge base."""
try:
manager = get_kb_manager()
default_kb = manager.get_default()
return {"default_kb": default_kb}
except Exception as e:
logger.error(f"Error getting default KB: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/default/{kb_name}")
async def set_default_kb(kb_name: str):
"""Set the default knowledge base."""
try:
manager, kb_name, _ = _writable_kb(kb_name)
# Verify KB exists
if kb_name not in manager.list_knowledge_bases():
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
manager.set_default(kb_name)
return {"status": "success", "default_kb": kb_name}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error setting default KB: {e}")
raise HTTPException(status_code=500, detail=str(e))
class ConnectObsidianRequest(BaseModel):
name: str
vault_path: str
@router.post("/connect-obsidian")
async def connect_obsidian_vault(payload: ConnectObsidianRequest):
"""Connect an existing Obsidian vault as a knowledge base.
Registers a pointer to the user's vault directory (``type: obsidian``) — no
upload, no index. The vault must be a directory the server can reach (i.e. a
local/self-hosted deployment); the Obsidian capability reads it live.
"""
name = (payload.name or "").strip()
vault_path = (payload.vault_path or "").strip()
if not name or not vault_path:
raise HTTPException(status_code=400, detail="Both name and vault_path are required.")
try:
folder = assert_path_allowed(vault_path)
manager = get_kb_manager()
entry = manager.register_obsidian_vault(name, str(folder))
return {"status": "connected", "name": name, "vault_path": entry["vault_path"]}
except ValueError as e:
# Missing/invalid path, disallowed location, or a name clash → 400.
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error connecting Obsidian vault: {e}")
raise HTTPException(status_code=500, detail=str(e))
class ProbeFolderRequest(BaseModel):
folder_path: str
rag_provider: str = DEFAULT_PROVIDER
class ConnectFolderRequest(BaseModel):
name: str
folder_path: str
rag_provider: str = DEFAULT_PROVIDER
@router.post("/probe-folder")
async def probe_linked_folder_route(payload: ProbeFolderRequest):
"""Inspect a local folder for a ready engine index before linking it.
Returns the probe verdict (ready index? embedding compatible? warnings?) so
the UI can present and confirm before any registration happens. Does not
create a knowledge base.
"""
folder_path = (payload.folder_path or "").strip()
if not folder_path:
raise HTTPException(status_code=400, detail="folder_path is required.")
try:
folder = assert_path_allowed(folder_path)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
result = probe_linked_folder(str(folder), payload.rag_provider)
return result.to_dict()
@router.post("/connect-folder")
async def connect_linked_folder_route(payload: ConnectFolderRequest):
"""Mount an existing engine index as a read-only ``linked`` knowledge base.
Re-probes server-side (never trusts the client's verdict), then registers a
pointer to the folder. Retrieval reads the index in place — no copy, no
re-index. Embedding-mismatch warnings do not block the link (the user may
switch embedding models later); a missing/invalid index does.
"""
name = (payload.name or "").strip()
folder_path = (payload.folder_path or "").strip()
if not name or not folder_path:
raise HTTPException(status_code=400, detail="Both name and folder_path are required.")
try:
folder = assert_path_allowed(folder_path)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
result = probe_linked_folder(str(folder), payload.rag_provider)
if not result.ok:
raise HTTPException(status_code=400, detail=result.error or "Folder is not linkable.")
stats = {
"embedding_model": result.embedding.index_model,
"doc_count": result.doc_count,
}
try:
manager = get_kb_manager()
entry = manager.register_linked_kb(
name,
str(folder),
result.provider,
stats=stats,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error connecting linked folder: {e}")
raise HTTPException(status_code=500, detail=str(e))
return {
"status": "connected",
"name": name,
"external_path": entry["external_path"],
"rag_provider": entry["rag_provider"],
"warnings": result.warnings,
}
class ProbeLightRagServerRequest(BaseModel):
server_url: str
api_key: str = ""
class ConnectLightRagServerRequest(BaseModel):
name: str
server_url: str
api_key: str = ""
search_mode: str = ""
@router.post("/probe-lightrag-server")
async def probe_lightrag_server_route(payload: ProbeLightRagServerRequest):
"""Test-connect to an external LightRAG server before binding a KB to it.
Returns the verdict (reachable? is it a LightRAG server? API key accepted?)
so the UI can confirm before any registration happens. Creates nothing.
"""
from deeptutor.services.rag.pipelines.lightrag_server.probe import probe_server
server_url = (payload.server_url or "").strip()
if not server_url:
raise HTTPException(status_code=400, detail="server_url is required.")
result = await probe_server(server_url, payload.api_key or "")
return result.to_dict()
@router.post("/connect-lightrag-server")
async def connect_lightrag_server_route(payload: ConnectLightRagServerRequest):
"""Connect an external LightRAG server as a retrieval-only knowledge base.
Re-probes server-side (never trusts the client's verdict), then registers a
pointer (``type: lightrag_server``). Retrieval is offloaded to the server's
``/query`` endpoint — no copy, no local index.
"""
from deeptutor.services.rag.pipelines.lightrag_server.config import SUPPORTED_MODES
from deeptutor.services.rag.pipelines.lightrag_server.probe import probe_server
name = (payload.name or "").strip()
server_url = (payload.server_url or "").strip()
if not name or not server_url:
raise HTTPException(status_code=400, detail="Both name and server_url are required.")
result = await probe_server(server_url, payload.api_key or "")
if not result.ok:
raise HTTPException(
status_code=400, detail=result.error or "Could not connect to the LightRAG server."
)
search_mode = (payload.search_mode or "").strip().lower()
if search_mode and search_mode not in SUPPORTED_MODES:
search_mode = ""
try:
manager = get_kb_manager()
entry = manager.register_lightrag_server_kb(
name,
result.base_url,
api_key=payload.api_key or "",
search_mode=search_mode,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error connecting LightRAG server: {e}")
raise HTTPException(status_code=500, detail=str(e))
return {
"status": "connected",
"name": name,
"server_url": entry["server_url"],
"rag_provider": entry["rag_provider"],
}
@router.get("/list", response_model=list[KnowledgeBaseInfo])
async def list_knowledge_bases():
"""List all available knowledge bases with their details."""
try:
manager = get_kb_manager()
kb_names = manager.list_knowledge_bases()
access_items = list_visible_kb_access()
access_by_id = {str(item.get("id") or ""): item for item in access_items}
own_prefix = "admin:kb:" if get_current_user().is_admin else "user:kb:"
logger.debug(f"Found {len(kb_names)} knowledge bases: {kb_names}")
result = []
errors = []
for name in kb_names:
try:
info = manager.get_info(name)
logger.debug(f"Successfully got info for KB '{name}': {info.get('statistics', {})}")
result.append(
KnowledgeBaseInfo(
id=f"{own_prefix}{info['name']}",
name=info["name"],
is_default=info["is_default"],
statistics=info.get("statistics", {}),
metadata=info.get("metadata"),
path=info.get("path"),
status=info.get("status"),
progress=info.get("progress"),
source="admin" if get_current_user().is_admin else "user",
assigned=False,
read_only=False,
provenance_label=access_by_id.get(f"{own_prefix}{info['name']}", {}).get(
"provenance_label"
),
)
)
except Exception as e:
error_msg = f"Error getting info for KB '{name}': {e}"
errors.append(error_msg)
logger.warning(f"{error_msg}\n{traceback.format_exc()}")
try:
kb_dir = manager.base_dir / name
if kb_dir.exists():
logger.debug(f"KB '{name}' directory exists, creating error fallback info")
fallback_progress = {
"stage": "error",
"message": "Failed to load knowledge base info.",
"error": error_msg,
}
result.append(
KnowledgeBaseInfo(
id=f"{own_prefix}{name}",
name=name,
is_default=name == manager.get_default(),
statistics={
"raw_documents": 0,
"images": 0,
"content_lists": 0,
"rag_initialized": False,
},
metadata={"name": name, "last_error": error_msg},
path=str(kb_dir),
status="error",
progress=fallback_progress,
source="admin" if get_current_user().is_admin else "user",
)
)
except Exception as fallback_err:
logger.error(f"Fallback also failed for KB '{name}': {fallback_err}")
if errors and not result:
error_detail = f"Failed to load knowledge bases. Errors: {'; '.join(errors)}"
logger.error(error_detail)
raise HTTPException(status_code=500, detail=error_detail)
if errors:
logger.warning(
f"Some KBs had errors, returning {len(result)} results. Errors: {errors}"
)
logger.debug(f"Returning {len(result)} knowledge bases")
if not get_current_user().is_admin:
own_ids = {item.id for item in result}
for access in access_items:
if access.get("source") != "admin" or access.get("id") in own_ids:
continue
if not access.get("available", True):
result.append(
KnowledgeBaseInfo(
id=str(access.get("id") or ""),
name=str(access.get("name") or ""),
is_default=False,
statistics={},
metadata={},
path=None,
status="unavailable",
progress=None,
source="admin",
assigned=True,
read_only=True,
provenance_label=str(access.get("provenance_label") or ""),
available=False,
)
)
continue
resource = resolve_kb(str(access.get("id") or access.get("name") or ""))
assigned_manager = manager_for_resource(resource)
try:
info = assigned_manager.get_info(resource.name)
result.append(
KnowledgeBaseInfo(
id=resource.id,
name=info["name"],
is_default=False,
statistics=info.get("statistics", {}),
metadata=info.get("metadata"),
path=None,
status=info.get("status"),
progress=info.get("progress"),
source="admin",
assigned=True,
read_only=True,
provenance_label=str(access.get("provenance_label") or ""),
)
)
except Exception as exc:
error_msg = f"Error getting assigned KB '{resource.name}': {exc}"
result.append(
KnowledgeBaseInfo(
id=resource.id,
name=resource.name,
is_default=False,
statistics={},
metadata={"name": resource.name, "last_error": error_msg},
status="error",
progress={
"stage": "error",
"message": "Failed to load assigned knowledge base info.",
"error": error_msg,
},
source="admin",
assigned=True,
read_only=True,
provenance_label=str(access.get("provenance_label") or ""),
)
)
return result
except HTTPException:
raise
except Exception as e:
error_msg = f"Error listing knowledge bases: {e}"
logger.error(f"{error_msg}\n{traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"Failed to list knowledge bases: {e!s}")
@router.get("/{kb_name}")
async def get_knowledge_base_details(kb_name: str):
"""Get detailed info for a specific KB."""
try:
resource = resolve_kb(kb_name)
manager = manager_for_resource(resource)
info = manager.get_info(resource.name)
info.update(
{
"id": resource.id,
"source": resource.source,
"assigned": resource.assigned,
"read_only": resource.read_only,
}
)
if resource.assigned:
info.pop("path", None)
return info
except HTTPException:
raise
except HTTPException:
raise
except ValueError:
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
def _resolve_kb_raw_dir(kb_name: str) -> Path:
"""Resolve the raw/ directory for a KB, validating that it exists."""
manager = _overridden_kb_manager()
if manager is not None:
resolved_name = _resolve_registered_kb_name(manager, kb_name)
return manager.get_knowledge_base_path(resolved_name) / "raw"
resource = resolve_kb(kb_name)
manager = manager_for_resource(resource)
kb_path = manager.get_knowledge_base_path(resource.name)
return kb_path / "raw"
def _resolve_kb_raw_file_or_404(kb_name: str, filename: str) -> Path:
"""Resolve a raw KB file while preventing traversal outside raw/."""
raw_dir = _resolve_kb_raw_dir(kb_name)
if not raw_dir.exists():
raise HTTPException(status_code=404, detail="File not found")
raw_resolved = raw_dir.resolve()
target = (raw_dir / filename).resolve()
try:
target.relative_to(raw_resolved)
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
if not target.exists() or not target.is_file():
raise HTTPException(status_code=404, detail="File not found")
return target
@router.get("/{kb_name}/files")
async def list_kb_raw_files(kb_name: str):
"""List raw documents under <kb>/raw/, recursing into folders.
``name`` is the POSIX path relative to ``raw/`` so the web client can
rebuild the folder tree. Folders (including empty ones) are returned as
``type: "folder"`` entries so user-created/uploaded structure shows even
before it holds any files. Folders are purely organizational and have no
effect on indexing or retrieval.
"""
raw_dir = _resolve_kb_raw_dir(kb_name)
if not raw_dir.exists() or not raw_dir.is_dir():
return {"files": []}
files = []
for entry in sorted(raw_dir.rglob("*"), key=lambda p: str(p).lower()):
rel = entry.relative_to(raw_dir).as_posix()
if entry.is_dir():
files.append({"name": rel, "type": "folder"})
continue
if not entry.is_file():
continue
try:
stat = entry.stat()
except OSError:
continue
media_type, _ = mimetypes.guess_type(entry.name)
files.append(
{
"name": rel,
"type": "file",
"size": stat.st_size,
"modified": stat.st_mtime,
"mime_type": media_type,
}
)
return {"files": files}
class CreateFolderPayload(BaseModel):
path: str
class MoveFilePayload(BaseModel):
source: str
dest_folder: str = ""
@router.post("/{kb_name}/folders")
async def create_kb_folder(kb_name: str, payload: CreateFolderPayload):
"""Create an (organizational) folder under <kb>/raw/. No retrieval effect."""
manager, kb_name, _ = _writable_kb(kb_name)
_assert_kb_writable_or_409(kb_name, _load_kb_entry_or_404(manager, kb_name))
raw_dir = manager.get_knowledge_base_path(kb_name) / "raw"
subdir = _sanitize_rel_subdir(payload.path)
if not subdir:
raise HTTPException(status_code=400, detail="Folder name is required")
target = _safe_join_raw(raw_dir, subdir)
target.mkdir(parents=True, exist_ok=True)
return {"status": "ok", "path": subdir}
@router.post("/{kb_name}/files/move")
async def move_kb_file(kb_name: str, payload: MoveFilePayload):
"""Move a file/folder between organizational folders (display only).
Moving never re-indexes: folders don't affect retrieval, so this is a pure
filesystem relocation under ``raw/``.
"""
manager, kb_name, _ = _writable_kb(kb_name)
_assert_kb_writable_or_409(kb_name, _load_kb_entry_or_404(manager, kb_name))
raw_dir = manager.get_knowledge_base_path(kb_name) / "raw"
source_rel = _sanitize_rel_subdir(payload.source)
if not source_rel:
raise HTTPException(status_code=400, detail="Source path is required")
src = _safe_join_raw(raw_dir, source_rel)
if not src.exists():
raise HTTPException(status_code=404, detail="Source not found")
dest_folder = _sanitize_rel_subdir(payload.dest_folder)
dest_dir = _safe_join_raw(raw_dir, dest_folder) if dest_folder else raw_dir.resolve()
dest = dest_dir / src.name
if dest.resolve() == src.resolve():
return {"status": "ok", "path": source_rel}
if src.is_dir() and dest_dir.resolve().is_relative_to(src.resolve()):
raise HTTPException(status_code=400, detail="Cannot move a folder into itself")
if dest.exists():
raise HTTPException(
status_code=409,
detail=f"'{src.name}' already exists in the target folder",
)
dest_dir.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), str(dest))
return {"status": "ok", "path": dest.relative_to(raw_dir.resolve()).as_posix()}
@router.get("/{kb_name}/file-preview-text/{filename:path}")
async def serve_kb_raw_file_text_preview(kb_name: str, filename: str):
"""Serve extracted plain text for a raw KB document preview."""
target = _resolve_kb_raw_file_or_404(kb_name, filename)
try:
text = extract_text_from_path(
target,
max_bytes=DocumentValidator.MAX_FILE_SIZE,
max_chars=MAX_EXTRACTED_CHARS_PER_DOC,
)
except DocumentExtractionError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except OSError as exc:
raise HTTPException(status_code=404, detail="File not found") from exc
return PlainTextResponse(text, media_type="text/plain; charset=utf-8")
@router.get("/{kb_name}/files/{filename:path}")
async def serve_kb_raw_file(kb_name: str, filename: str):
"""Serve a single raw document for inline preview / download.
Resolution is sandboxed to the KB's raw/ directory; any path that
escapes via traversal yields 403.
"""
target = _resolve_kb_raw_file_or_404(kb_name, filename)
media_type, _ = mimetypes.guess_type(target.name)
return FileResponse(
target,
media_type=media_type or "application/octet-stream",
filename=target.name,
content_disposition_type="inline",
)
@router.delete("/{kb_name}/files/{filename:path}")
async def delete_kb_file(kb_name: str, filename: str):
"""Remove a single raw document from a knowledge base.
Unlike deleting the whole KB, this works while the KB is in an *error*
state — that is the point: a file that failed to parse (e.g. one that
exceeds the cloud parser's page limit) can be dropped without deleting and
rebuilding everything. Connected (read-only) and legacy KBs are still
rejected. Vectors are not pruned here; ``was_indexed`` tells the caller
whether a re-index is needed to purge the file from retrieval.
"""
manager, kb_name, _ = _writable_kb(kb_name)
_assert_kb_writable_or_409(kb_name, _load_kb_entry_or_404(manager, kb_name))
target = _resolve_kb_raw_file_or_404(kb_name, filename)
kb_dir = manager.get_knowledge_base_path(kb_name)
removal = remove_raw_document(Path(kb_dir), target)
return {
"status": "ok",
"path": removal.rel_path,
"was_indexed": removal.was_indexed,
}
@router.delete("/{kb_name}")
async def delete_knowledge_base(kb_name: str):
"""Delete a knowledge base."""
try:
manager, resolved_name, _ = _writable_kb(kb_name)
success = manager.delete_knowledge_base(resolved_name, confirm=True)
if not success:
raise HTTPException(status_code=400, detail="Failed to delete knowledge base")
logger.info(f"KB '{kb_name}' deleted")
return {"message": f"Knowledge base '{kb_name}' deleted successfully"}
except ValueError:
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/tasks/{task_id}/stream")
async def stream_task_logs(task_id: str):
"""Stream task-specific logs for knowledge-base operations."""
manager = get_task_stream_manager()
manager.ensure_task(task_id)
return StreamingResponse(
manager.stream(task_id),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.post("/{kb_name}/upload")
async def upload_files(
kb_name: str,
background_tasks: BackgroundTasks,
files: list[UploadFile] = File(...),
rag_provider: str = Form(None),
rel_paths: list[str] = Form(None),
):
"""Upload files to a knowledge base and process them in background."""
try:
manager, kb_name, kb_base_dir = _writable_kb(kb_name)
kb_path = manager.get_knowledge_base_path(kb_name)
raw_dir = kb_path / "raw"
raw_dir.mkdir(parents=True, exist_ok=True)
requested_provider = None
if rag_provider is not None and str(rag_provider).strip():
requested_provider = _validate_registered_provider(rag_provider)
kb_entry = _load_kb_entry_or_404(manager, kb_name)
_assert_kb_writable_or_409(kb_name, kb_entry)
kb_provider = _validate_registered_provider(
kb_entry.get("rag_provider") or DEFAULT_PROVIDER
)
if requested_provider and requested_provider != kb_provider:
raise HTTPException(
status_code=400,
detail=(
f"Requested provider '{requested_provider}' does not match KB provider '{kb_provider}'. "
"A knowledge base is locked to the engine it was created with."
),
)
_assert_provider_ready(kb_provider)
_enforce_provider_formats(kb_provider, files)
allowed_extensions = FileTypeRouter.get_supported_extensions()
# ``.zip`` is accepted as an upload container; its members are
# validated against ``allowed_extensions`` during extraction and the
# archive itself is never indexed (``safe_extract_zip`` skips ``.zip``).
upload_extensions = allowed_extensions | {".zip"}
_validate_upload_batch(files, allowed_extensions=upload_extensions, rel_paths=rel_paths)
uploaded_files, uploaded_file_paths = _save_uploaded_files(
files, raw_dir, allowed_extensions=upload_extensions, rel_paths=rel_paths
)
task_id = _build_unique_task_id("kb_upload", kb_name)
get_task_stream_manager().ensure_task(task_id)
logger.info(f"Uploading {len(uploaded_files)} files to KB '{kb_name}'")
background_tasks.add_task(
run_upload_processing_task,
kb_name=kb_name,
base_dir=str(kb_base_dir),
uploaded_file_paths=uploaded_file_paths,
task_id=task_id,
rag_provider=kb_provider,
)
return {
"message": f"Uploaded {len(uploaded_files)} files. Processing in background.",
"files": uploaded_files,
"task_id": task_id,
}
except HTTPException:
raise
except ValueError:
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
except Exception as e:
# Unexpected failure (Server error)
formatted_error = format_exception_message(e)
raise HTTPException(status_code=500, detail=formatted_error) from e
@router.post("/create")
async def create_knowledge_base(
background_tasks: BackgroundTasks,
name: str = Form(...),
files: list[UploadFile] = File(...),
rag_provider: str = Form(DEFAULT_PROVIDER),
rel_paths: list[str] = Form(None),
):
"""Create a new knowledge base and initialize it with files."""
try:
try:
name = validate_knowledge_base_name(name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
manager = get_kb_manager()
kb_base_dir = _current_kb_base_dir()
if name in manager.list_knowledge_bases():
raise HTTPException(status_code=400, detail=f"Knowledge base '{name}' already exists")
rag_provider = _validate_registered_provider(rag_provider)
_assert_provider_ready(rag_provider)
_enforce_provider_formats(rag_provider, files)
allowed_extensions = FileTypeRouter.get_supported_extensions()
_validate_upload_batch(files, allowed_extensions=allowed_extensions, rel_paths=rel_paths)
logger.info(f"Creating KB: {name} (provider={rag_provider})")
task_id = _build_unique_task_id("kb_init", name)
get_task_stream_manager().ensure_task(task_id)
# Register KB to kb_config.json immediately with "initializing" status
# This ensures the KB appears in the list right away
manager.update_kb_status(
name=name,
status="initializing",
progress={
"stage": "initializing",
"message": "Initializing knowledge base...",
"percent": 0,
"current": 0,
"total": len(files),
"task_id": task_id,
},
)
# Also store rag_provider in config (reload and update)
manager.config = manager._load_config()
if name in manager.config.get("knowledge_bases", {}):
manager.config["knowledge_bases"][name]["rag_provider"] = rag_provider
manager.config["knowledge_bases"][name]["needs_reindex"] = False
manager._save_config()
progress_tracker = ProgressTracker(name, kb_base_dir)
initializer = KnowledgeBaseInitializer(
kb_name=name,
base_dir=str(kb_base_dir),
progress_tracker=progress_tracker,
rag_provider=rag_provider,
)
initializer.create_directory_structure()
progress_tracker.task_id = task_id
manager = get_kb_manager()
if name not in manager.list_knowledge_bases():
logger.warning(f"KB {name} not found in config, registering manually")
initializer._register_to_config()
uploaded_files, _ = _save_uploaded_files(
files, initializer.raw_dir, allowed_extensions=allowed_extensions, rel_paths=rel_paths
)
progress_tracker.update(
ProgressStage.PROCESSING_DOCUMENTS,
f"Saved {len(uploaded_files)} files, preparing to process...",
current=0,
total=len(uploaded_files),
)
background_tasks.add_task(run_initialization_task, initializer, task_id)
logger.info(f"KB '{name}' created, processing {len(uploaded_files)} files in background")
return {
"message": f"Knowledge base '{name}' created. Processing {len(uploaded_files)} files in background.",
"name": name,
"files": uploaded_files,
"task_id": task_id,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to create KB: {e}")
logger.debug(traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
async def run_reindex_task(kb_name: str, base_dir: str, task_id: str, signature_hash: str) -> None:
"""Re-index a KB's raw documents against the currently-active embedding config.
Each ``(profile, model, dimension, base_url)`` combination gets its own
flat ``<kb>/version-N/`` storage directory. Prior versions are preserved
untouched so switching the active embedding model back to a
previously-indexed one reuses the existing version with no extra work.
"""
task_manager = TaskIDManager.get_instance()
task_stream_manager = get_task_stream_manager()
task_stream_manager.ensure_task(task_id)
with capture_task_logs(task_id):
try:
base_path = Path(base_dir)
kb_dir = base_path / kb_name
raw_dir = kb_dir / "raw"
if not raw_dir.is_dir():
raise FileNotFoundError(f"KB '{kb_name}' has no `raw/` directory; cannot reindex.")
file_paths = [
str(path)
for path in FileTypeRouter.collect_supported_files(raw_dir, recursive=True)
]
if not file_paths:
raise ValueError(f"KB '{kb_name}' has no source files in `raw/` to reindex.")
_task_log(
task_id,
f"Re-indexing '{kb_name}' ({len(file_paths)} files) against signature {signature_hash}",
)
progress_tracker = ProgressTracker(kb_name, base_path)
progress_tracker.task_id = task_id
progress_tracker.update(
ProgressStage.PROCESSING_DOCUMENTS,
f"Re-indexing {len(file_paths)} document(s) with the active embedding model...",
current=0,
total=len(file_paths),
)
from deeptutor.services.rag.service import RAGService
# provider=None → RAGService resolves the KB's DeepTutor-bound
# engine, so re-indexing a PageIndex/LightRAG/GraphRAG KB stays on
# that provider rather than forcing the default pipeline.
rag_service = RAGService(kb_base_dir=str(base_path), provider=None)
def _on_progress(batch_num: int, total_batches: int) -> None:
progress_tracker.update(
ProgressStage.PROCESSING_DOCUMENTS,
f"Embedding batches: {batch_num}/{total_batches}",
current=batch_num,
total=total_batches,
)
# The pipeline now raises the underlying error (embedding API
# failure, parse error, etc.) so it surfaces in the task log
# rather than being swallowed into a generic wrapper. A False
# return is reserved for "no documents to index" — surface that
# specifically too.
success = await rag_service.initialize(
kb_name=kb_name,
file_paths=file_paths,
progress_callback=_on_progress,
)
if not success:
raise RuntimeError(f"Re-index found no valid documents to index in '{kb_name}'.")
completed_at = datetime.now().isoformat()
metadata_file = kb_dir / "metadata.json"
try:
metadata = {}
if metadata_file.exists():
with open(metadata_file, encoding="utf-8") as handle:
loaded_metadata = json.load(handle)
if isinstance(loaded_metadata, dict):
metadata = loaded_metadata
metadata["last_updated"] = completed_at
metadata["last_indexed_at"] = completed_at
metadata["last_indexed_count"] = len(file_paths)
metadata["last_indexed_action"] = "reindex"
with open(metadata_file, "w", encoding="utf-8") as handle:
json.dump(metadata, handle, indent=2, ensure_ascii=False)
except Exception as meta_err:
logger.warning(
"Failed to update re-index metadata for '%s': %s",
kb_name,
meta_err,
)
manager = get_kb_manager()
manager.update_kb_status(
name=kb_name,
status="ready",
progress={
"stage": "completed",
"message": "Re-index complete",
"percent": 100,
"current": len(file_paths),
"total": len(file_paths),
"task_id": task_id,
"timestamp": completed_at,
"indexed_count": len(file_paths),
"index_changed": True,
"index_action": "reindex",
},
)
# Clear the legacy mismatch / needs_reindex flags now that an
# index version matching the active config exists on disk.
kb_entry = manager.config.get("knowledge_bases", {}).get(kb_name) or {}
mutated = False
if kb_entry.get("needs_reindex"):
kb_entry["needs_reindex"] = False
mutated = True
if kb_entry.get("embedding_mismatch"):
kb_entry.pop("embedding_mismatch", None)
mutated = True
if mutated:
manager._save_config()
_task_log(task_id, f"Re-index of '{kb_name}' complete", level="success")
task_manager.update_task_status(task_id, "completed")
task_stream_manager.emit_complete(task_id, f"Re-index of '{kb_name}' complete")
except Exception as e:
import traceback as _tb
error_msg = str(e)
trace = _tb.format_exc()
_task_log(task_id, f"Re-index failed: {error_msg}", level="error")
_task_log(task_id, f"Stack trace:\n{trace}", level="error")
task_manager.update_task_status(task_id, "error", error=error_msg)
try:
ProgressTracker(kb_name, Path(base_dir)).update(
ProgressStage.ERROR,
f"Re-index failed: {error_msg}",
error=error_msg,
)
except Exception:
pass
task_stream_manager.emit_failed(task_id, error_msg, details=trace)
@router.post("/{kb_name}/reindex")
async def reindex_knowledge_base(
kb_name: str,
background_tasks: BackgroundTasks,
):
"""Re-index ``kb_name`` through its bound RAG provider.
LlamaIndex still keys versions by the active embedding model. The other
providers keep synthetic provider-keyed versions, so they should rebuild
without requiring an embedding-signature precheck.
"""
try:
manager, kb_name, kb_base_dir = _writable_kb(kb_name)
kb_entry = _load_kb_entry_or_404(manager, kb_name)
_assert_not_connected_kb(kb_name, kb_entry)
force_reindex = str(kb_entry.get("status") or "").lower() == "error"
kb_provider = _validate_registered_provider(
kb_entry.get("rag_provider") or DEFAULT_PROVIDER
)
_assert_provider_ready(kb_provider)
kb_dir = kb_base_dir / kb_name
signature_hash = kb_provider
if provider_uses_embedding_versions(kb_provider):
from deeptutor.services.rag.embedding_signature import signature_from_embedding_config
from deeptutor.services.rag.index_versioning import (
find_matching_version,
)
signature = signature_from_embedding_config()
if signature is None:
raise HTTPException(
status_code=409,
detail=(
"No embedding model is configured. Set up the embedding "
"profile in Settings before re-indexing."
),
)
signature_hash = signature.hash()
matching_version = find_matching_version(kb_dir, signature)
matching_valid = _matching_index_is_valid(kb_name, matching_version)
if (
matching_version
and matching_version.get("layout") == "flat"
and matching_valid
and not force_reindex
):
return {
"message": (
f"Knowledge base '{kb_name}' already has an index for the "
"active embedding configuration; no reindex needed."
),
"task_id": None,
"signature": signature_hash,
"noop": True,
}
task_id = _build_unique_task_id("kb_reindex", kb_name)
get_task_stream_manager().ensure_task(task_id)
manager.update_kb_status(
name=kb_name,
status="initializing",
progress={
"stage": "starting",
"message": "Queueing re-index...",
"percent": 0,
"task_id": task_id,
"timestamp": datetime.now().isoformat(),
},
)
background_tasks.add_task(
run_reindex_task,
kb_name=kb_name,
base_dir=str(kb_base_dir),
task_id=task_id,
signature_hash=signature_hash,
)
return {
"message": f"Re-indexing '{kb_name}' in the background.",
"task_id": task_id,
"signature": signature_hash,
"noop": False,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to start reindex for '{kb_name}': {e}")
raise HTTPException(status_code=500, detail=format_exception_message(e))
@router.post("/{kb_name}/retry")
async def retry_knowledge_base(
kb_name: str,
background_tasks: BackgroundTasks,
):
"""Retry a failed KB initialization/indexing run from its stored raw files."""
try:
manager, resolved_name, _ = _writable_kb(kb_name)
kb_entry = _load_kb_entry_or_404(manager, resolved_name)
status = str(kb_entry.get("status") or "").lower()
progress = kb_entry.get("progress") if isinstance(kb_entry.get("progress"), dict) else {}
progress_stage = str(progress.get("stage") or "").lower()
if status != "error" and progress_stage != "error":
raise HTTPException(
status_code=409,
detail=(
f"Knowledge base '{resolved_name}' is not in an error state. "
"Use re-index when you want to rebuild a healthy knowledge base."
),
)
return await reindex_knowledge_base(resolved_name, background_tasks)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to retry KB '{kb_name}': {e}")
raise HTTPException(status_code=500, detail=format_exception_message(e))
@router.get("/{kb_name}/progress")
async def get_progress(kb_name: str):
"""Get initialization progress for a knowledge base"""
try:
resource = resolve_kb(kb_name)
progress_tracker = ProgressTracker(resource.name, resource.base_dir)
progress = progress_tracker.get_progress()
if progress is None:
return {"status": "not_started", "message": "Initialization not started"}
return progress
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{kb_name}/progress/clear")
async def clear_progress(kb_name: str):
"""Clear progress file for a knowledge base (useful for stuck states)"""
try:
_, resolved_name, base_dir = _writable_kb(kb_name)
progress_tracker = ProgressTracker(resolved_name, base_dir)
progress_tracker.clear()
return {"status": "success", "message": f"Progress cleared for {kb_name}"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.websocket("/{kb_name}/progress/ws")
async def websocket_progress(websocket: WebSocket, kb_name: str):
"""WebSocket endpoint for real-time progress updates"""
from deeptutor.api.routers.auth import ws_auth_failed, ws_require_auth
from deeptutor.multi_user.context import reset_current_user
user_token = await ws_require_auth(websocket)
if user_token is ws_auth_failed:
return
await websocket.accept()
broadcaster = ProgressBroadcaster.get_instance()
try:
await broadcaster.connect(kb_name, websocket)
base_dir = _current_kb_base_dir()
progress_tracker = ProgressTracker(kb_name, base_dir)
initial_progress = progress_tracker.get_progress()
expected_task_id = websocket.query_params.get("task_id")
try:
kb_info = KnowledgeBaseManager(base_dir=str(base_dir)).get_info(kb_name)
kb_is_ready = bool(kb_info.get("statistics", {}).get("rag_initialized"))
except Exception:
kb_is_ready = False
# Fast path: no active task — send current state and close immediately
# This prevents infinite polling loops for ready or legacy KBs.
has_active_task = False
if initial_progress:
stage = initial_progress.get("stage")
if stage not in ("completed", "error", None):
ts = initial_progress.get("timestamp")
if ts:
try:
age = (datetime.now() - datetime.fromisoformat(ts)).total_seconds()
has_active_task = age < 120
except Exception:
pass
if not has_active_task and not expected_task_id:
if kb_is_ready:
await websocket.send_json(
{
"type": "progress",
"data": {
"stage": "completed",
"message": "Knowledge base is ready.",
"percent": 100,
"current": 1,
"total": 1,
},
}
)
else:
await websocket.send_json(
{
"type": "progress",
"data": initial_progress
or {
"stage": "error",
"message": "Knowledge base needs reindex or initialization.",
},
}
)
return
if initial_progress:
stage = initial_progress.get("stage")
timestamp = initial_progress.get("timestamp")
progress_task_id = initial_progress.get("task_id")
should_send = False
if expected_task_id and progress_task_id and progress_task_id != expected_task_id:
should_send = False
elif stage == "error" or not kb_is_ready:
should_send = True
elif stage != "completed" and timestamp:
try:
progress_time = datetime.fromisoformat(timestamp)
now = datetime.now()
age_seconds = (now - progress_time).total_seconds()
if age_seconds < 300:
should_send = True
except Exception:
pass
if should_send:
await websocket.send_json({"type": "progress", "data": initial_progress})
last_progress = initial_progress
last_timestamp = initial_progress.get("timestamp") if initial_progress else None
while True:
try:
try:
await asyncio.wait_for(websocket.receive_text(), timeout=1.0)
except asyncio.TimeoutError:
current_progress = progress_tracker.get_progress()
if current_progress:
progress_task_id = current_progress.get("task_id")
if (
expected_task_id
and progress_task_id
and progress_task_id != expected_task_id
):
continue
current_timestamp = current_progress.get("timestamp")
if current_timestamp != last_timestamp:
await websocket.send_json(
{"type": "progress", "data": current_progress}
)
last_progress = current_progress
last_timestamp = current_timestamp
if current_progress.get("stage") in ["completed", "error"]:
await asyncio.sleep(3)
break
continue
except WebSocketDisconnect:
break
except Exception:
break
except Exception as e:
logger.debug(f"Progress WS error: {e}")
try:
await websocket.send_json({"type": "error", "message": str(e)})
except Exception:
pass
finally:
await broadcaster.disconnect(kb_name, websocket)
try:
await websocket.close()
except Exception:
pass
if user_token is not None:
try:
reset_current_user(user_token)
except Exception:
pass
@router.post("/{kb_name}/link-folder", response_model=LinkedFolderInfo)
async def link_folder(kb_name: str, request: LinkFolderRequest):
"""
Link a local folder to a knowledge base.
This allows syncing documents from a local folder (which can be
synced with SharePoint, Google Drive, OneLake, etc.) to the KB.
The folder path supports:
- Absolute paths: /Users/name/Documents or C:\\Users\\name\\Documents
- Home directory: ~/Documents
- Relative paths (resolved from server working directory)
"""
try:
manager, resolved_name, _ = _writable_kb(kb_name)
_assert_not_connected_kb(resolved_name, _load_kb_entry_or_404(manager, resolved_name))
folder_info = manager.link_folder(resolved_name, request.folder_path)
logger.info(f"Linked folder '{request.folder_path}' to KB '{kb_name}'")
return LinkedFolderInfo(**folder_info)
except HTTPException:
raise
except ValueError as e:
error_msg = str(e)
if "not found" in error_msg.lower():
raise HTTPException(status_code=404, detail=error_msg)
raise HTTPException(status_code=400, detail=error_msg)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{kb_name}/linked-folders", response_model=list[LinkedFolderInfo])
async def get_linked_folders(kb_name: str):
"""Get list of linked folders for a knowledge base."""
try:
resource = resolve_kb(kb_name)
manager = manager_for_resource(resource)
folders = manager.get_linked_folders(resource.name)
return [LinkedFolderInfo(**f) for f in folders]
except HTTPException:
raise
except ValueError:
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{kb_name}/linked-folders/{folder_id}")
async def unlink_folder(kb_name: str, folder_id: str):
"""Unlink a folder from a knowledge base."""
try:
manager, resolved_name, _ = _writable_kb(kb_name)
success = manager.unlink_folder(resolved_name, folder_id)
if not success:
raise HTTPException(status_code=404, detail=f"Folder '{folder_id}' not found")
logger.info(f"Unlinked folder '{folder_id}' from KB '{kb_name}'")
return {"message": "Folder unlinked successfully", "folder_id": folder_id}
except HTTPException:
raise
except ValueError:
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{kb_name}/sync-folder/{folder_id}")
async def sync_folder(kb_name: str, folder_id: str, background_tasks: BackgroundTasks):
"""
Sync files from a linked folder to the knowledge base.
This scans the linked folder for supported documents and processes
any new files that haven't been added yet.
"""
try:
manager, kb_name, kb_base_dir = _writable_kb(kb_name)
kb_entry = _load_kb_entry_or_404(manager, kb_name)
_assert_kb_writable_or_409(kb_name, kb_entry)
kb_provider = _validate_registered_provider(
kb_entry.get("rag_provider") or DEFAULT_PROVIDER
)
# Get linked folders and find the one with matching ID
folders = manager.get_linked_folders(kb_name)
folder_info = next((f for f in folders if f["id"] == folder_id), None)
if not folder_info:
raise HTTPException(status_code=404, detail=f"Linked folder '{folder_id}' not found")
folder_path = folder_info["path"]
# Check for changes (new or modified files)
changes = manager.detect_folder_changes(kb_name, folder_id)
files_to_process = changes["new_files"] + changes["modified_files"]
if not files_to_process:
return {"message": "No new or modified files to sync", "files": [], "file_count": 0}
logger.info(
f"Syncing {len(files_to_process)} files from folder '{folder_path}' to KB '{kb_name}'"
)
task_id = _build_unique_task_id("kb_upload", f"{kb_name}_folder_{folder_id}")
get_task_stream_manager().ensure_task(task_id)
# NOTE: We DO NOT update sync state here anymore.
# It is updated in run_upload_processing_task only after successful processing.
# This prevents marking files as synced if processing fails (race condition fix).
# Add background task to process files
background_tasks.add_task(
run_upload_processing_task,
kb_name=kb_name,
base_dir=str(kb_base_dir),
uploaded_file_paths=files_to_process,
task_id=task_id,
rag_provider=kb_provider,
folder_id=folder_id, # Pass folder_id to update state on success
)
return {
"message": f"Syncing {len(files_to_process)} files from linked folder",
"folder_path": folder_path,
"new_files": changes["new_count"],
"modified_files": changes["modified_count"],
"file_count": len(files_to_process),
"task_id": task_id,
}
except HTTPException:
raise
except ValueError:
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))