Feat: add skills space to context engine (#13908)

### What problem does this PR solve?

issue #13714

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
Yingfeng
2026-04-30 12:36:03 +08:00
committed by GitHub
parent bb3b99f0a5
commit 4ee0702aed
101 changed files with 19161 additions and 633 deletions

View File

@@ -187,7 +187,9 @@ async def delete(tenant_id: str = None):
return get_error_argument_result(err)
try:
success, result = await file_api_service.delete_files(tenant_id, req["ids"])
# Get Authorization header to pass to Go backend
auth_header = request.headers.get("Authorization", "")
success, result = await file_api_service.delete_files(tenant_id, req["ids"], auth_header)
if success:
return get_result(data=result)
else:

View File

@@ -67,14 +67,14 @@ async def upload_file(tenant_id: str, pf_id: str, file_objs: list):
if not e:
return False, "Folder not found!"
last_folder = await thread_pool_exec(
FileService.create_folder, file, file_id_list[len_id_list - 1], file_obj_names, len_id_list
FileService.create_folder, file, file_id_list[len_id_list - 1], file_obj_names, len_id_list, tenant_id, tenant_id
)
else:
e, file = await thread_pool_exec(FileService.get_by_id, file_id_list[len_id_list - 2])
if not e:
return False, "Folder not found!"
last_folder = await thread_pool_exec(
FileService.create_folder, file, file_id_list[len_id_list - 2], file_obj_names, len_id_list
FileService.create_folder, file, file_id_list[len_id_list - 2], file_obj_names, len_id_list, tenant_id, tenant_id
)
filetype = filename_type(file_obj_names[file_len - 1])
@@ -158,6 +158,7 @@ def list_files(tenant_id: str, args: dict):
root_folder = FileService.get_root_folder(tenant_id)
pf_id = root_folder["id"]
FileService.init_knowledgebase_docs(pf_id, tenant_id)
FileService.init_skills_folder(pf_id, tenant_id)
e, file = FileService.get_by_id(pf_id)
if not e:
@@ -203,17 +204,110 @@ def get_all_parent_folders(file_id: str):
return True, {"parent_folders": [pf.to_json() for pf in parent_folders]}
async def delete_files(uid: str, file_ids: list):
async def delete_files(uid: str, file_ids: list, auth_header: str = ""):
"""
Delete files/folders with team permission check and recursive deletion.
:param uid: user ID
:param file_ids: list of file IDs to delete
:param auth_header: Authorization header for Go backend API calls
:return: (success, result) or (success, error_message)
"""
errors: list[str] = []
success_count = 0
def _get_space_uuid_by_name(tenant_id, space_name, authorization):
"""Get space UUID by space name from Go backend"""
try:
import requests
host = getattr(settings, 'HOST_IP', '127.0.0.1')
# Go service runs on port+4 (9384 by default)
port = getattr(settings, 'HOST_PORT', 9380) + 4
service_url = f"http://{host}:{port}"
# List all spaces and find the one matching the name
url = f"{service_url}/api/v1/skills/spaces"
headers = {"Content-Type": "application/json"}
if authorization:
headers["Authorization"] = authorization
response = requests.get(url, headers=headers, timeout=10)
if response.status_code == 200:
data = response.json()
if data.get("code") == 0:
spaces = data.get("data", {}).get("spaces", [])
for space in spaces:
if space.get("name") == space_name:
return space.get("id")
except Exception as e:
logging.warning(f"Error getting space UUID: {e}")
return None
def _delete_skill_index(tenant_id, space_name, skill_name, authorization):
"""Delete skill index from Go backend.
Returns:
bool: True if deletion succeeded (HTTP 200), False otherwise.
"""
try:
import requests
from urllib.parse import quote
# Construct service URL from settings
host = getattr(settings, 'HOST_IP', '127.0.0.1')
# Go service runs on port+4 (9384 by default)
port = getattr(settings, 'HOST_PORT', 9380) + 4
service_url = f"http://{host}:{port}"
# Get space UUID from space name
space_uuid = _get_space_uuid_by_name(tenant_id, space_name, authorization)
space_id = space_uuid if space_uuid else space_name
url = f"{service_url}/api/v1/skills/index?skill_id={quote(skill_name)}&space_id={quote(space_id)}"
headers = {"Content-Type": "application/json"}
if authorization:
headers["Authorization"] = authorization
response = requests.delete(url, headers=headers, timeout=10)
if response.status_code == 200:
try:
data = response.json()
if data.get("code") == 0:
logging.info(
f"Successfully deleted skill index: space={space_name}, skill={skill_name}, "
f"status={response.status_code}, code=0"
)
return True
else:
app_code = data.get("code", "unknown")
app_msg = data.get("message", "no message")
logging.error(
f"Failed to delete skill index: space={space_name}, skill={skill_name}, "
f"status={response.status_code}, app_code={app_code}, app_msg={app_msg}, "
f"response={response.text}"
)
return False
except ValueError as json_err:
# JSON decode error - treat as failure
logging.error(
f"Failed to parse delete response JSON: space={space_name}, skill={skill_name}, "
f"error={json_err}, raw_response={response.text}"
)
return False
else:
logging.error(
f"Failed to delete skill index: space={space_name}, skill={skill_name}, "
f"status={response.status_code}, response={response.text}"
)
return False
except Exception as e:
logging.error(
f"Exception deleting skill index: space={space_name}, skill={skill_name}, error={e}"
)
return False
def _delete_single_file(file) -> int:
try:
if file.location:
@@ -254,9 +348,64 @@ async def delete_files(uid: str, file_ids: list):
return 0
def _delete_folder_recursive(folder, tenant_id):
def _find_ancestor_skill_space(folder_id, tenant_id):
"""Walk up the folder hierarchy to find an ancestor with source_type == 'skill_space'.
Returns:
tuple: (success, folder) where folder has source_type == 'skill_space', or (False, None)
"""
visited = set()
current_id = folder_id
while current_id and current_id not in visited:
visited.add(current_id)
success, folder = FileService.get_by_id(current_id)
if not success or not folder:
return False, None
if folder.source_type == "skill_space":
return True, folder
# Move to parent
current_id = folder.parent_id
return False, None
def _delete_folder_recursive(folder, tenant_id) -> int:
deleted = 0
current_space_name = None
is_space_folder = folder.source_type == "skill_space"
is_skill_folder = False
if not is_space_folder:
parent_success, parent_folder = FileService.get_by_id(folder.parent_id)
if parent_success and parent_folder and parent_folder.source_type == "skill_space":
is_skill_folder = True
current_space_name = parent_folder.name
logging.info(f"Identified skill folder '{folder.name}' (parent space: {current_space_name})")
else:
ancestor_success, ancestor_folder = _find_ancestor_skill_space(folder.parent_id, tenant_id)
if ancestor_success and ancestor_folder:
is_skill_folder = True
current_space_name = ancestor_folder.name
logging.info(f"Identified skill folder '{folder.name}' (ancestor space: {current_space_name})")
if is_space_folder:
current_space_name = folder.name
logging.info(f"Processing space folder '{folder.name}' - will delete all skill indexes within")
if is_skill_folder and current_space_name and not is_space_folder:
logging.info(f"Deleting skill index for skill '{folder.name}' in space '{current_space_name}'")
index_deleted = _delete_skill_index(tenant_id, current_space_name, folder.name, auth_header)
if not index_deleted:
logging.error(
f"Aborting folder deletion due to index deletion failure: "
f"folder={folder.name}, space={current_space_name}"
)
errors.append(
f"Failed to delete skill index for folder '{folder.name}' in space '{current_space_name}'. "
f"Folder deletion aborted to prevent orphaned indexes."
)
return deleted
sub_files = FileService.list_all_files_by_parent_id(folder.id)
logging.info(f"Folder '{folder.name}': found {len(sub_files)} children to delete")
for sub_file in sub_files:
if sub_file.type == FileType.FOLDER.value:
deleted += _delete_folder_recursive(sub_file, tenant_id)
@@ -269,6 +418,16 @@ async def delete_files(uid: str, file_ids: list):
errors.append(f"Failed to delete folder record {folder.id}: {e}")
else:
deleted += 1
try:
if hasattr(settings.STORAGE_IMPL, 'remove_bucket'):
logging.info(f"Removing storage bucket for folder '{folder.name}' (id={folder.id})")
settings.STORAGE_IMPL.remove_bucket(folder.id)
else:
logging.debug(f"Storage implementation does not support remove_bucket, skipping for folder '{folder.name}'")
except Exception as e:
logging.warning(f"Failed to remove storage bucket for folder '{folder.name}' (id={folder.id}): {e}")
return deleted
def _rm_sync():
@@ -288,6 +447,9 @@ async def delete_files(uid: str, file_ids: list):
if file.source_type == FileSource.KNOWLEDGEBASE:
continue
if file.source_type == "skill_space":
continue
if file.type == FileType.FOLDER.value:
success_count += _delete_folder_recursive(file, uid)
continue

View File

@@ -74,3 +74,4 @@ PIPELINE_SPECIAL_PROGRESS_FREEZE_TASK_TYPES = {PipelineTaskType.RAPTOR.lower(),
KNOWLEDGEBASE_FOLDER_NAME=".knowledgebase"
SKILLS_FOLDER_NAME="skills"

View File

@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
import xxhash
from peewee import fn
from api.db import KNOWLEDGEBASE_FOLDER_NAME, FileType
from api.db import KNOWLEDGEBASE_FOLDER_NAME, SKILLS_FOLDER_NAME, FileType
from api.db.db_models import DB, Document, File, File2Document, Knowledgebase, Task
from api.db.services import duplicate_name
from api.db.services.common_service import CommonService
@@ -191,23 +191,24 @@ class FileService(CommonService):
@classmethod
@DB.connection_context()
def create_folder(cls, file, parent_id, name, count):
from api.apps import current_user
def create_folder(cls, file, parent_id, name, count, tenant_id, created_by):
# Recursively create folder structure
# Args:
# file: Current file object
# parent_id: Parent folder ID
# name: List of folder names to create
# count: Current depth in creation
# tenant_id: Tenant ID
# created_by: Created by user ID
# Returns:
# Created file object
if count > len(name) - 2:
return file
else:
file = cls.insert(
{"id": get_uuid(), "parent_id": parent_id, "tenant_id": current_user.id, "created_by": current_user.id, "name": name[count], "location": "", "size": 0, "type": FileType.FOLDER.value}
{"id": get_uuid(), "parent_id": parent_id, "tenant_id": tenant_id, "created_by": created_by, "name": name[count], "location": "", "size": 0, "type": FileType.FOLDER.value}
)
return cls.create_folder(file, file.id, name, count + 1)
return cls.create_folder(file, file.id, name, count + 1, tenant_id, created_by)
@classmethod
@DB.connection_context()
@@ -293,6 +294,28 @@ class FileService(CommonService):
cls.save(**file)
return file
@classmethod
@DB.connection_context()
def init_skills_folder(cls, root_id, tenant_id):
# Initialize skills folder if not exists
# Args:
# root_id: Root folder ID
# tenant_id: Tenant ID
for _ in cls.model.select().where((cls.model.name == SKILLS_FOLDER_NAME) & (cls.model.parent_id == root_id)):
return
file_id = get_uuid()
file = {
"id": file_id,
"parent_id": root_id,
"tenant_id": tenant_id,
"created_by": tenant_id,
"name": SKILLS_FOLDER_NAME,
"type": FileType.FOLDER.value,
"size": 0,
"location": "",
}
cls.save(**file)
@classmethod
@DB.connection_context()
def init_knowledgebase_docs(cls, root_id, tenant_id):