Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 133 additions & 2 deletions backend/apps/northbound_knowledge_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
LimitExceededError,
UnauthorizedError,
)
from consts.model import ProcessParams
from consts.model import HybridSearchRequest, ProcessParams
from database.knowledge_db import get_index_name_by_knowledge_name
from services.file_management_service import (
upload_files_impl,
get_file_url_impl,
Expand All @@ -20,7 +21,11 @@
)
from services.northbound_service import NorthboundContext
from services.redis_service import get_redis_service
from services.vectordatabase_service import ElasticSearchService, get_vector_db_core
from services.vectordatabase_service import (
ElasticSearchService,
KnowledgeBaseNeedsModelConfigError,
get_vector_db_core,
)
from utils.auth_utils import generate_session_jwt
from utils.file_management_utils import trigger_data_process

Expand Down Expand Up @@ -221,6 +226,132 @@ async def get_index_files(
detail="Error getting index files")


@router.post("/indices/{index_name}/chunks")
async def get_index_chunks(
request: Request,
index_name: Annotated[str, Path(..., description="Name of the index")],
page: Annotated[
Optional[int],
Query(description="Page number (1-based) for pagination"),
] = None,
page_size: Annotated[
Optional[int],
Query(description="Number of records per page for pagination"),
] = None,
path_or_url: Annotated[
Optional[str],
Query(description="Filter chunks by document path_or_url"),
] = None,
):
"""Get chunks from the specified index, with optional pagination.

Restricted to asset administrators (same auth as get_list_indices).
"""
try:
ctx = await _require_asset_owner_context(request)
vdb_core = get_vector_db_core(db_type=VectorDatabaseType.ELASTICSEARCH)

if path_or_url is not None and not check_file_access(
path_or_url, ctx.user_id, ctx.tenant_id
):
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="You don't have permission to access this file",
)

return ElasticSearchService.get_index_chunks(
index_name=index_name,
page=page,
page_size=page_size,
path_or_url=path_or_url,
vdb_core=vdb_core,
)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail=str(e)
)
except LimitExceededError as e:
logger.exception("Rate limit exceeded while getting chunks")
raise HTTPException(
status_code=HTTPStatus.TOO_MANY_REQUESTS,
detail=RATE_LIMIT_EXCEEDED_DETAIL)
except UnauthorizedError as e:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED, detail=str(e))
except HTTPException:
raise
except Exception:
logger.exception("Error getting chunks for index")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Error getting chunks")


@router.post("/indices/search/hybrid")
async def hybrid_search(
request: Request,
payload: HybridSearchRequest,
):
"""Run a hybrid (accurate + semantic) search across indices.

Restricted to asset administrators (same auth as get_list_indices).
"""
try:
ctx = await _require_asset_owner_context(request)
vdb_core = get_vector_db_core(db_type=VectorDatabaseType.ELASTICSEARCH)

resolved_index_names: List[str] = []
for requested_name in payload.index_names:
try:
resolved_name = get_index_name_by_knowledge_name(
requested_name, ctx.tenant_id
)
except Exception:
resolved_name = requested_name
resolved_index_names.append(resolved_name)

return ElasticSearchService.search_hybrid(
index_names=resolved_index_names,
query=payload.query,
tenant_id=ctx.tenant_id,
top_k=payload.top_k,
weight_accurate=payload.weight_accurate,
vdb_core=vdb_core,
)
except KnowledgeBaseNeedsModelConfigError as exc:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail={
"error_type": "KNOWLEDGE_BASE_NEEDS_MODEL_CONFIG",
"index_name": exc.index_name,
"message": exc.message,
"suggestion": (
"Please select an embedding model for this knowledge base "
"before searching."
),
},
)
except ValueError as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
)
except LimitExceededError as e:
logger.exception("Rate limit exceeded while running hybrid search")
raise HTTPException(
status_code=HTTPStatus.TOO_MANY_REQUESTS,
detail=RATE_LIMIT_EXCEEDED_DETAIL)
except UnauthorizedError as e:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED, detail=str(e))
except HTTPException:
raise
except Exception:
logger.exception("Error executing hybrid search")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Error executing hybrid search")


@router.delete("/indices/{index_name}/documents")
async def delete_documents(
request: Request,
Expand Down
10 changes: 8 additions & 2 deletions backend/services/asset_owner_visibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@


ASSET_OWNER_RESOURCES_ROUTE = "/asset-owner-resources"
OWNER_MANAGE_ROUTE = "/owner-manage"

_ASSET_OWNER_FEATURE_NAV_ROUTES = frozenset({
ASSET_OWNER_RESOURCES_ROUTE, # legacy
OWNER_MANAGE_ROUTE, # current SU page
})


def is_asset_owner_enabled() -> bool:
Expand All @@ -33,10 +39,10 @@ def require_asset_owner_enabled() -> None:
def filter_accessible_routes_for_asset_owner_feature(
accessible_routes: List[str],
) -> List[str]:
"""Remove asset-owner nav route when the ASSET_OWNER feature flag is disabled."""
"""Remove asset-owner nav routes when the ASSET_OWNER feature flag is disabled."""
if ENABLE_ASSET_OWNER_ROLE:
return accessible_routes
return [r for r in accessible_routes if r != ASSET_OWNER_RESOURCES_ROUTE]
return [r for r in accessible_routes if r not in _ASSET_OWNER_FEATURE_NAV_ROUTES]


def can_view_skill(caller_tenant_id: Optional[str], skill_tenant_id: Optional[str]) -> bool:
Expand Down
8 changes: 4 additions & 4 deletions deploy/sql/migrations/v2.2.2_0622_update_left_nav_menu.sql
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ ADD COLUMN IF NOT EXISTS parent_key VARCHAR(50);
-- SU Menus (root level)
INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype) VALUES
(1001, 'SU', 'VISIBILITY', 'LEFT_NAV_MENU', '/'),
(1002, 'SU', 'VISIBILITY', 'LEFT_NAV_MENU', '/resource-manage');
(1002, 'SU', 'VISIBILITY', 'LEFT_NAV_MENU', '/resource-manage'),
(1003, 'SU', 'VISIBILITY', 'LEFT_NAV_MENU', '/owner-manage');
Comment thread
Lifeng-Chen marked this conversation as resolved.

-- ADMIN Menus (root level)
INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype) VALUES
Expand Down Expand Up @@ -86,13 +87,12 @@ INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_
(1411, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/mcp-space', '/resource-space'),
(1412, 'SPEED', 'VISIBILITY', 'LEFT_NAV_MENU', '/skill-space', '/resource-space');

-- ASSET_OWNER Menus (root level)
-- ASSET_OWNER Menus (root level; /owner-manage is SU-only, see v2.3.0_0713_move_owner_manage_to_su.sql)
INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype) VALUES
(1501, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/'),
(1502, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/chat'),
(1503, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/agent-dev'),
(1504, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/resource-space'),
(1505, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/owner-manage');
(1504, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/resource-space');
INSERT INTO nexent.role_permission_t (role_permission_id, user_role, permission_category, permission_type, permission_subtype, parent_key) VALUES
(1506, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/models', '/agent-dev'),
(1507, 'ASSET_OWNER', 'VISIBILITY', 'LEFT_NAV_MENU', '/knowledges', '/agent-dev'),
Expand Down
45 changes: 45 additions & 0 deletions deploy/sql/migrations/v2.3.0_0713_move_owner_manage_to_su.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
-- ============================================================
-- Move /owner-manage left-nav from ASSET_OWNER to SU
-- Migration Date: 2026-07-13
-- ============================================================
-- ASSET_OWNER no longer sees the asset-admin resource management page.
-- SU gains /owner-manage (id 1003) alongside existing / and /resource-manage.
-- ============================================================

BEGIN;

-- Remove ASSET_OWNER access to /owner-manage
DELETE FROM nexent.role_permission_t
WHERE role_permission_id = 1505
OR (
user_role = 'ASSET_OWNER'
AND permission_category = 'VISIBILITY'

Check failure on line 16 in deploy/sql/migrations/v2.3.0_0713_move_owner_manage_to_su.sql

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal 3 times.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ9eaNxbcb2IvRWWLus2&open=AZ9eaNxbcb2IvRWWLus2&pullRequest=3416
AND permission_type = 'LEFT_NAV_MENU'

Check failure on line 17 in deploy/sql/migrations/v2.3.0_0713_move_owner_manage_to_su.sql

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal 3 times.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ9eaNxbcb2IvRWWLus1&open=AZ9eaNxbcb2IvRWWLus1&pullRequest=3416
AND permission_subtype = '/owner-manage'

Check failure on line 18 in deploy/sql/migrations/v2.3.0_0713_move_owner_manage_to_su.sql

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal 3 times.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AZ9eaNxbcb2IvRWWLus3&open=AZ9eaNxbcb2IvRWWLus3&pullRequest=3416
);

-- Grant SU access to /owner-manage (idempotent)
DELETE FROM nexent.role_permission_t
WHERE role_permission_id = 1003
OR (
user_role = 'SU'
AND permission_category = 'VISIBILITY'
AND permission_type = 'LEFT_NAV_MENU'
AND permission_subtype = '/owner-manage'
);

INSERT INTO nexent.role_permission_t (
role_permission_id,
user_role,
permission_category,
permission_type,
permission_subtype
) VALUES (
1003,
'SU',
'VISIBILITY',
'LEFT_NAV_MENU',
'/owner-manage'
);

COMMIT;
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export function MineAgentsView({
const normalizedQuery = searchQuery.trim().toLowerCase();

const handleCreateAgent = () => {
router.push(`/${locale}/agents?create=true`);
router.push(`/${locale}/agents?create=true&from=agent-space&tab=mine`);
};

const handleImportAgent = async () => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1747,7 +1747,7 @@
"sidebar.modelManagement": "Model Management",
"sidebar.memoryManagement": "Memory Management",
"sidebar.resourceManage": "Resource Management",
"sidebar.ownerManage": "Owner Management",
"sidebar.ownerManage": "Asset Administrator Resource Management",
"sidebar.userManagement": "Profile",
"sidebar.tenantResources": "Tenant Resources",
"sidebar.assetOwnerResources": "Asset Administrator Resources",
Expand Down
2 changes: 1 addition & 1 deletion frontend/public/locales/zh/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1719,7 +1719,7 @@
"sidebar.modelConfig": "模型配置",
"sidebar.memoryConfig": "记忆配置",
"sidebar.resourceManage": "资源管理",
"sidebar.ownerManage": "资产管理",
"sidebar.ownerManage": "资产管理员资源管理",
"sidebar.userManagement": "个人信息",
"sidebar.tenantResources": "租户资源",
"sidebar.assetOwnerResources": "资产管理员资源",
Expand Down
45 changes: 45 additions & 0 deletions test/backend/app/test_northbound_base_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from fastapi import APIRouter, HTTPException
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field

# ---------------------------------------------------------------------------
# Path setup
Expand Down Expand Up @@ -85,15 +86,35 @@ class NorthboundContext:
# services.vectordatabase_service - stub to avoid heavy SDK imports
vectordb_service_module = types.ModuleType("services.vectordatabase_service")

class KnowledgeBaseNeedsModelConfigError(Exception):
def __init__(self, index_name: str, message: str = None):
self.index_name = index_name
self.message = message or (
f"Knowledge base '{index_name}' needs an embedding model to be configured"
)
super().__init__(self.message)

class _ElasticSearchServiceStub:
@staticmethod
def list_indices(*args, **kwargs):
return {"indices": []}

vectordb_service_module.ElasticSearchService = _ElasticSearchServiceStub
vectordb_service_module.KnowledgeBaseNeedsModelConfigError = KnowledgeBaseNeedsModelConfigError
vectordb_service_module.get_vector_db_core = MagicMock()
sys.modules["services.vectordatabase_service"] = vectordb_service_module

# database.knowledge_db - stub used by northbound_knowledge_app
database_pkg = types.ModuleType("database")
database_pkg.__path__ = [os.path.join(backend_dir, "database")]
sys.modules["database"] = database_pkg

knowledge_db_module = types.ModuleType("database.knowledge_db")
knowledge_db_module.get_index_name_by_knowledge_name = MagicMock(
side_effect=lambda name, tenant_id: f"resolved_{name}"
)
sys.modules["database.knowledge_db"] = knowledge_db_module

# ---------------------------------------------------------------------------
# BLOCK 2: Mock minimal consts modules needed by apps layer
# ---------------------------------------------------------------------------
Expand All @@ -102,12 +123,19 @@ def list_indices(*args, **kwargs):
sys.modules['consts'] = consts_module

# consts.model - only exceptions and AgentRequest needed by apps
class HybridSearchRequest(BaseModel):
query: str = Field(..., min_length=1)
index_names: list = Field(..., min_length=1)
top_k: int = Field(10, ge=1, le=100)
weight_accurate: float = Field(0.5, ge=0.0, le=1.0)

consts_model_module = types.ModuleType("consts.model")
consts_model_module.LimitExceededError = type("LimitExceededError", (Exception,), {})
consts_model_module.UnauthorizedError = type("UnauthorizedError", (Exception,), {})
consts_model_module.SignatureValidationError = type("SignatureValidationError", (Exception,), {})
consts_model_module.AgentRequest = type("AgentRequest", (), {})
consts_model_module.ProcessParams = type("ProcessParams", (), {})
consts_model_module.HybridSearchRequest = HybridSearchRequest
consts_module.model = consts_model_module
sys.modules['consts.model'] = consts_model_module

Expand All @@ -125,6 +153,16 @@ def list_indices(*args, **kwargs):
consts_module.exceptions = consts_exceptions_module
sys.modules['consts.exceptions'] = consts_exceptions_module

# consts.const - stub used by northbound_knowledge_app
consts_const_module = types.ModuleType("consts.const")
consts_const_module.ASSET_OWNER_TENANT_ID = "asset_owner_tenant_id"

class VectorDatabaseType:
ELASTICSEARCH = "elasticsearch"

consts_const_module.VectorDatabaseType = VectorDatabaseType
sys.modules["consts.const"] = consts_const_module

# ---------------------------------------------------------------------------
# BLOCK 3: Mock remaining dependencies referenced by northbound_app
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -152,6 +190,13 @@ async def _get_northbound_context_fake(request):
northbound_app_module._get_northbound_context = _get_northbound_context_fake
sys.modules['apps.northbound_app'] = northbound_app_module

# Mock apps.file_management_app - used by northbound_knowledge_app
file_management_app_module = types.ModuleType("apps.file_management_app")
file_management_app_module.build_content_disposition_header = MagicMock(
return_value='attachment; filename="file.txt"'
)
sys.modules["apps.file_management_app"] = file_management_app_module

# Mock apps.app_factory (imported by northbound_base_app)
app_factory_module = types.ModuleType("apps.app_factory")

Expand Down
Loading
Loading