diff --git a/backend/ext_components/aidp/apps/aidp_mgmt_app.py b/backend/ext_components/aidp/apps/aidp_mgmt_app.py index 367ad71090..927960dec8 100644 --- a/backend/ext_components/aidp/apps/aidp_mgmt_app.py +++ b/backend/ext_components/aidp/apps/aidp_mgmt_app.py @@ -26,7 +26,8 @@ import httpx from fastapi import APIRouter, File, HTTPException, Path, Query, Request, UploadFile -from fastapi.responses import JSONResponse, StreamingResponse +from fastapi.responses import JSONResponse, Response, StreamingResponse +from nexent.core.concurrency import run_blocking from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from sqlalchemy.exc import IntegrityError from starlette.background import BackgroundTask @@ -58,30 +59,38 @@ READ_ONLY, ) from ext_components.aidp.services.aidp_service import ( + AIDP_CHUNK_REMOVE_MAX_IDS, + AIDP_CHUNK_TEXT_MAX_LENGTH, _timestamp_to_iso, count_aidp_docs_impl, + create_aidp_chunk_impl, create_aidp_kb_impl, delete_aidp_kb_impl, + get_aidp_chunk_impl, get_aidp_graph_template_impl, get_aidp_kb_impl, get_bound_agents, list_aidp_channels_impl, + list_aidp_chunks_impl, list_aidp_doc_history_impl, list_aidp_docs_impl, list_aidp_models_impl, merge_graph_config_update, + remove_aidp_chunks_impl, remove_aidp_docs_impl, retry_aidp_docs_impl, + search_aidp_chunks_impl, select_aidp_channel, stream_aidp_doc_impl, stream_aidp_preview_impl, + update_aidp_chunk_impl, update_aidp_kb_impl, upload_aidp_docs_impl, ) from management.db.identity.user_tenant import get_user_role_by_tenant from management.services.permission.identity.display import enrich_cas_usernames from management.services.tag.library import TagManagementService -from nexent.core.concurrency import run_blocking + aidp_mgmt_router = APIRouter(prefix="/aidp-mgmt") logger = logging.getLogger("aidp_mgmt_app") @@ -297,6 +306,63 @@ class DownloadAidpDocumentRequest(BaseModel): file_uuid: UUID = Field(..., description="AIDP file UUID") +def _normalize_chunk_text(value: str) -> str: + """Trim chunk text and enforce the AIDP 1-10240 character contract. + + The AIDP contract counts characters, not bytes, and rejects an empty text. + Both rules are enforced here so an invalid write never reaches AIDP; the + returned value is the trimmed text that actually gets stored. + """ + text = value.strip() + if not text: + raise ValueError("chunk_text must not be blank") + if len(text) > AIDP_CHUNK_TEXT_MAX_LENGTH: + raise ValueError( + f"chunk_text must not exceed {AIDP_CHUNK_TEXT_MAX_LENGTH} characters" + ) + return text + + +class CreateAidpChunkRequest(BaseModel): + """Body for adding one chunk to a file.""" + + model_config = ConfigDict(extra="forbid") + + file_uuid: UUID = Field(..., description="AIDP file UUID the chunk belongs to") + chunk_text: str = Field(..., description="Chunk text (1-10240 characters)") + + @field_validator("chunk_text") + @classmethod + def validate_chunk_text(cls, value: str) -> str: + return _normalize_chunk_text(value) + + +class UpdateAidpChunkRequest(BaseModel): + """Body for updating one chunk's text; no other field is writable.""" + + model_config = ConfigDict(extra="forbid") + + chunk_text: str = Field(..., description="Chunk text (1-10240 characters)") + + @field_validator("chunk_text") + @classmethod + def validate_chunk_text(cls, value: str) -> str: + return _normalize_chunk_text(value) + + +class RemoveAidpChunksRequest(BaseModel): + """Body for removing chunks; AIDP accepts one batch of 1-2000 ids.""" + + model_config = ConfigDict(extra="forbid") + + chunk_ids: List[Annotated[int, Field(strict=True, ge=0)]] = Field( + ..., + min_length=1, + max_length=AIDP_CHUNK_REMOVE_MAX_IDS, + description=f"Chunk ids (1-{AIDP_CHUNK_REMOVE_MAX_IDS} items)", + ) + + # --------------------------------------------------------------------------- # Auth helpers # --------------------------------------------------------------------------- @@ -1177,6 +1243,230 @@ async def preview_document( str(file_uuid), ) return _stream_aidp_preview(aidp_response) +@aidp_mgmt_router.get("/knowledge-bases/{kds_id}/chunks") +async def list_chunks( + request: Request, + kds_id: Annotated[str, Path(description="Knowledge base ID")], + file_uuid: Annotated[UUID, Query(description="AIDP file UUID")], + page: Annotated[int, Query(ge=1, description="Page number starting from 1")] = 1, + page_size: Annotated[int, Query(ge=1, le=100, description="Page size from 1 to 100")] = 20, +) -> JSONResponse: + """List the chunks AIDP generated for one file. + + AIDP addresses the list by ``file_uuid`` only: the contract has no keyword + filter, so the page cannot offer a content search. Its ``next_link`` is + returned on every page, including the last, so ``has_more`` is derived from + ``total_count`` instead of the link. + """ + user_id, tenant_id = await _auth(request) + perms.require_permission(kds_id, user_id, tenant_id, required="READ") + + server_url, api_key = _credentials() + result = await run_blocking( + "aidp-list-chunks", + list_aidp_chunks_impl, + server_url, + api_key, + kds_id, + str(file_uuid), + page, + page_size, + lane="control-io", + owner="config", + ) + payload = dict(result or {}) + rows = payload.get("value") + if not isinstance(rows, list): + raise AppException( + ErrorCode.AIDP_RESPONSE_ERROR, + "Unexpected AIDP chunk list response format", + ) + raw_total = payload.get("total_count") + total_reliable = ( + isinstance(raw_total, int) and not isinstance(raw_total, bool) and raw_total >= 0 + ) + total = int(raw_total) if total_reliable else (page - 1) * page_size + len(rows) + has_more = ( + total > page * page_size + if total_reliable + else len(rows) >= page_size + ) + payload.update({ + "total_count": total, + "has_more": has_more, + "total_reliable": total_reliable, + }) + return JSONResponse(status_code=HTTPStatus.OK, content=payload) + + +@aidp_mgmt_router.get("/knowledge-bases/{kds_id}/chunks/search") +async def search_chunks( + request: Request, + kds_id: Annotated[str, Path(description="Knowledge base ID")], + file_uuid: Annotated[UUID, Query(description="AIDP file UUID")], + file_name: Annotated[ + str, + Query( + min_length=1, + max_length=255, + description="Current file name used by the AIDP file-name filter", + ), + ], + query: Annotated[ + str, + Query(min_length=1, max_length=2048, description="Chunk content query"), + ], + topk: Annotated[int, Query(ge=1, le=100)] = 20, +) -> JSONResponse: + """Search chunk content in one file through AIDP FusionSearch. + + AIDP's list endpoint has no keyword parameter, so this dedicated route + builds a FusionSearch request constrained by the documented + ``metadata_condition.original_file_name`` filter. ``file_uuid`` keeps the + route tied to the current file; ``file_name`` is the filter value. + """ + user_id, tenant_id = await _auth(request) + perms.require_permission(kds_id, user_id, tenant_id, required="READ") + + server_url, api_key = _credentials() + result = await run_blocking( + "aidp-search-chunks", + search_aidp_chunks_impl, + server_url, + api_key, + kds_id, + file_name, + query, + topk, + lane="control-io", + owner="config", + ) + return JSONResponse(status_code=HTTPStatus.OK, content=result) + + +@aidp_mgmt_router.post("/knowledge-bases/{kds_id}/chunks") +async def create_chunk( + request: Request, + kds_id: Annotated[str, Path(description="Knowledge base ID")], + body: CreateAidpChunkRequest, +) -> JSONResponse: + """Add one text chunk to a file; only ``file_uuid`` and text are forwarded.""" + user_id, tenant_id = await _auth(request) + perms.require_permission(kds_id, user_id, tenant_id, required="EDIT") + + server_url, api_key = _credentials() + result = await run_blocking( + "aidp-create-chunk", + create_aidp_chunk_impl, + server_url, + api_key, + kds_id, + str(body.file_uuid), + body.chunk_text, + lane="control-io", + owner="config", + ) + return JSONResponse(status_code=HTTPStatus.OK, content=result) + + +@aidp_mgmt_router.delete("/knowledge-bases/{kds_id}/chunks") +async def remove_chunks( + request: Request, + kds_id: Annotated[str, Path(description="Knowledge base ID")], + body: RemoveAidpChunksRequest, +) -> Response: + """Remove up to 2000 chunks; AIDP receives a JSON body on DELETE. + + AIDP answers ``204 No Content``, so this proxy mirrors the empty success + response instead of inventing a result payload. + """ + user_id, tenant_id = await _auth(request) + perms.require_permission(kds_id, user_id, tenant_id, required="EDIT") + + server_url, api_key = _credentials() + await run_blocking( + "aidp-remove-chunks", + remove_aidp_chunks_impl, + server_url, + api_key, + kds_id, + body.chunk_ids, + lane="control-io", + owner="config", + ) + return Response(status_code=HTTPStatus.NO_CONTENT) + + +@aidp_mgmt_router.get("/knowledge-bases/{kds_id}/chunks/{chunk_id}") +async def get_chunk( + request: Request, + kds_id: Annotated[str, Path(description="Knowledge base ID")], + chunk_id: Annotated[int, Path(ge=0, description="AIDP chunk ID")], +) -> JSONResponse: + """Read one chunk's full text and read-only metadata.""" + user_id, tenant_id = await _auth(request) + perms.require_permission(kds_id, user_id, tenant_id, required="READ") + + server_url, api_key = _credentials() + result = await run_blocking( + "aidp-get-chunk", + get_aidp_chunk_impl, + server_url, + api_key, + kds_id, + chunk_id, + lane="control-io", + owner="config", + ) + return JSONResponse(status_code=HTTPStatus.OK, content=result) + + +@aidp_mgmt_router.patch("/knowledge-bases/{kds_id}/chunks/{chunk_id}") +async def update_chunk( + request: Request, + kds_id: Annotated[str, Path(description="Knowledge base ID")], + chunk_id: Annotated[int, Path(ge=0, description="AIDP chunk ID")], + body: UpdateAidpChunkRequest, +) -> JSONResponse: + """Update one chunk's text; AIDP declares no other writable field.""" + user_id, tenant_id = await _auth(request) + perms.require_permission(kds_id, user_id, tenant_id, required="EDIT") + + server_url, api_key = _credentials() + # Only TEXT chunks are editable; TABLE/IMAGE are readable but their content + # is not a text field. Check the actual chunk type AIDP reports instead of + # inferring it from the source file's extension or upload size class. + current = await run_blocking( + "aidp-get-chunk-for-update", + get_aidp_chunk_impl, + server_url, + api_key, + kds_id, + chunk_id, + lane="control-io", + owner="config", + ) + chunk_type = str((current or {}).get("chunk_type") or "").strip().upper() + if chunk_type != "TEXT": + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Only TEXT chunks can be edited", + ) + + result = await run_blocking( + "aidp-update-chunk", + update_aidp_chunk_impl, + server_url, + api_key, + kds_id, + chunk_id, + body.chunk_text, + lane="control-io", + owner="config", + ) + return JSONResponse(status_code=HTTPStatus.OK, content=result) + + @aidp_mgmt_router.get("/knowledge-bases/{kds_id}/files") async def list_ingested_files( request: Request, diff --git a/backend/ext_components/aidp/services/aidp_service.py b/backend/ext_components/aidp/services/aidp_service.py index 150908439a..714e104314 100644 --- a/backend/ext_components/aidp/services/aidp_service.py +++ b/backend/ext_components/aidp/services/aidp_service.py @@ -12,6 +12,10 @@ from urllib.parse import quote, urljoin import httpx +from nexent.core.ext_components.aidp.retrieval_config import ( + build_payload, + resolve_config, +) from nexent.utils.http_client_manager import http_client_manager from contracts.constants.environment import AIDP_TENANT_ID @@ -1831,6 +1835,271 @@ def list_aidp_docs_impl( ) +# ==================== Document chunks (semantic slicing) ==================== + +# AIDP caps one chunk text at 10240 characters and one removal request at 2000 +# ids; both limits are enforced again at the management routes. +AIDP_CHUNK_TEXT_MAX_LENGTH = 10240 +AIDP_CHUNK_REMOVE_MAX_IDS = 2000 + + +def _get_chunks_path(kds_id: str, tenant_id: str | None = None) -> str: + """Build the knowledge-base scoped document chunk API path. + + The chunk endpoints follow the same tenant-scoped shape as the other + knowledge-file endpoints, but address the file through a query parameter + (list) or the chunk id in the path (detail/update). + """ + return ( + f"/KnowledgeBase/Tenants/{_resolve_tenant_id(tenant_id)}" + f"/KnowledgeBases/{kds_id}/Chunks" + ) + + +def _normalize_chunk_id(value: Any) -> Any: + """Return a chunk id in the numeric form the AIDP contract declares. + + AIDP types ``chunk_id`` (and ``page_ids``) as int64, but deployments + serialize them as numeric strings. Converting numeric strings keeps ids + usable as route parameters, while anything else is returned unchanged + instead of being coerced into a different identifier. + """ + if isinstance(value, (bool, int)): + return value + if isinstance(value, str) and value.strip().isdecimal(): + return int(value.strip()) + return value + + +def _normalize_aidp_chunk(raw: Dict[str, Any]) -> Dict[str, Any]: + """Normalize one AIDP chunk item without inventing missing fields. + + Only types are corrected: the text, type, name/path and metadata are passed + through as reported so the page can show an empty state instead of made-up + content for anything AIDP omitted. + """ + out = dict(raw) + if "chunk_id" in out: + out["chunk_id"] = _normalize_chunk_id(out.get("chunk_id")) + + page_ids = out.get("page_ids") + if isinstance(page_ids, list): + normalized_pages = [_normalize_chunk_id(page) for page in page_ids] + out["page_ids"] = [ + page + for page in normalized_pages + if isinstance(page, int) and not isinstance(page, bool) + ] + elif page_ids is not None: + out["page_ids"] = [] + + metadata = out.get("metadata") + if metadata is not None and not isinstance(metadata, dict): + out["metadata"] = {} + return out + + +def _request_aidp_json( + server_url: str, + api_key: str, + method: str, + path: str, + operation: str, + *, + params: Dict[str, Any] | None = None, + json_body: Dict[str, Any] | None = None, +) -> Any: + """Issue one AIDP JSON request and return the decoded body. + + An empty body is decoded as ``{}`` because AIDP answers a chunk removal + with ``204 No Content``; treating that as a parse failure would report a + successful write as an error. + """ + normalized_url = _validate_params(server_url, api_key) + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + url = urljoin(f"{normalized_url}/", path) + logger.info("AIDP %s: %s %s", operation, method, url) + + try: + client = http_client_manager.get_sync_client( + base_url=normalized_url, + timeout=_AIDP_READ_TIMEOUT_SECONDS, + verify_ssl=False, + ) + request_kwargs: Dict[str, Any] = {"headers": headers} + if params is not None: + request_kwargs["params"] = params + if json_body is not None: + request_kwargs["json"] = json_body + if method == "DELETE": + # The convenience method does not accept a JSON body. + response = client.request(method, url, **request_kwargs) + else: + method_fn = getattr(client, method.lower()) + response = method_fn(url, **request_kwargs) + response.raise_for_status() + if not response.content: + return {} + return response.json() + except httpx.RequestError as e: + logger.exception("AIDP request failed: %s", e) + raise AppException( + ErrorCode.AIDP_CONNECTION_ERROR, + f"AIDP API request failed: {str(e)}", + ) + except httpx.HTTPStatusError as e: + _raise_aidp_http_error(e, operation) + except ValueError as e: + logger.exception("Failed to parse AIDP API response: %s", e) + raise AppException( + ErrorCode.AIDP_RESPONSE_ERROR, + f"Failed to parse AIDP API response: {str(e)}", + ) + + +def list_aidp_chunks_impl( + server_url: str, + api_key: str, + kds_id: str, + file_uuid: str, + page: int = 1, + page_size: int = 20, +) -> Dict[str, Any]: + """List the chunks AIDP generated for one file of a knowledge base. + + ``file_uuid`` is the only query condition the contract declares: there is + no keyword filter, so a content search cannot be forwarded from the page. + """ + result = _request_aidp_json( + server_url, + api_key, + "GET", + _get_chunks_path(kds_id), + "list-chunks", + params={"file_uuid": file_uuid, "page": page, "page_size": page_size}, + ) + if not isinstance(result, dict): + raise AppException( + ErrorCode.AIDP_RESPONSE_ERROR, + "Unexpected AIDP chunk list response format", + ) + items = _extract_list_payload(result) + if items is None: + raise AppException( + ErrorCode.AIDP_RESPONSE_ERROR, + "AIDP chunk list response does not contain a chunk list", + ) + result["value"] = [ + _normalize_aidp_chunk(item) if isinstance(item, dict) else item + for item in items + ] + return result + + +def get_aidp_chunk_impl( + server_url: str, + api_key: str, + kds_id: str, + chunk_id: int, +) -> Dict[str, Any]: + """Read one chunk's current text and metadata.""" + result = _request_aidp_json( + server_url, + api_key, + "GET", + f"{_get_chunks_path(kds_id)}/{chunk_id}", + "get-chunk", + ) + if not isinstance(result, dict): + raise AppException( + ErrorCode.AIDP_RESPONSE_ERROR, + "Unexpected AIDP chunk detail response format", + ) + return _normalize_aidp_chunk(result) + + +def create_aidp_chunk_impl( + server_url: str, + api_key: str, + kds_id: str, + file_uuid: str, + chunk_text: str, +) -> Dict[str, Any]: + """Add one text chunk to a file; AIDP answers with the new ``chunk_id``. + + AIDP appends the chunk to the file's chunk list and documents that the + payload carries only ``file_uuid`` and ``chunk_text``; there is no page, + order or coordinate input. The example response spells ``chunk_id`` as a + numeric string, so it is normalized to the numeric form every other chunk + endpoint uses before the value reaches the caller. + """ + result = _request_aidp_json( + server_url, + api_key, + "PUT", + _get_chunks_path(kds_id), + "create-chunk", + json_body={"file_uuid": file_uuid, "chunk_text": chunk_text}, + ) + if not isinstance(result, dict): + return {} + if "chunk_id" in result: + result["chunk_id"] = _normalize_chunk_id(result["chunk_id"]) + return result + + +def update_aidp_chunk_impl( + server_url: str, + api_key: str, + kds_id: str, + chunk_id: int, + chunk_text: str, +) -> Dict[str, Any]: + """Update one chunk's text and return the full chunk AIDP sends back. + + AIDP regenerates the chunk vector after a text edit and answers with the + updated ``chunk_id``/``chunk_text``/``chunk_type``/``page_ids``/``metadata`` + object, which the page uses to refresh the detail instead of assuming how + the update affected those fields. + """ + result = _request_aidp_json( + server_url, + api_key, + "PATCH", + f"{_get_chunks_path(kds_id)}/{chunk_id}", + "update-chunk", + json_body={"chunk_text": chunk_text}, + ) + if not isinstance(result, dict): + return {} + return _normalize_aidp_chunk(result) + + +def remove_aidp_chunks_impl( + server_url: str, + api_key: str, + kds_id: str, + chunk_ids: List[int], +) -> Dict[str, Any]: + """Remove up to 2000 chunks with ``DELETE /Chunks`` and a JSON body. + + AIDP answers ``204 No Content`` without a body; an empty response is the + success contract, not a parse failure. + """ + result = _request_aidp_json( + server_url, + api_key, + "DELETE", + _get_chunks_path(kds_id), + "remove-chunks", + json_body={"chunk_ids": list(chunk_ids)}, + ) + return result if isinstance(result, dict) else {} + + # ==================== Knowledge-file history (all-status listing) ==================== # Channel entries are read through alias lists because AIDP deployments spell @@ -2335,6 +2604,114 @@ def _get_retrieval_path(tenant_id: str | None = None) -> str: return f"/KnowledgeBase/Tenants/{_resolve_tenant_id(tenant_id)}/Retrieval/FusionSearch" +def search_aidp_chunks_impl( + server_url: str, + api_key: str, + kds_id: str, + file_name: str, + query: str, + top_k: int = 20, + tenant_id: str | None = None, +) -> Dict[str, Any]: + """Search chunks of one file through AIDP FusionSearch. + + This is a dedicated read path for the chunk search box. It deliberately + does not call the ``aidp_search`` agent tool: the tool has its own + model-facing response shaping, while this endpoint needs raw chunk hits + for the current file only. The wire payload is built with the same SDK + retrieval policy used by agents, then constrained with the documented + ``metadata_condition.original_file_name`` filter. Only vector and + full-text channels consume that filter, so graph retrieval is excluded. + """ + normalized_file_name = file_name.strip() + normalized_query = query.strip() + if not normalized_file_name or not normalized_query: + raise AppException(ErrorCode.COMMON_VALIDATION_ERROR, "file_name and query are required") + + config = resolve_config({ + "search_method": "vector_search|full_text_search", + "top_k": top_k, + "hybrid_top_k": top_k, + "vector_candidates": top_k, + "full_text_candidates": top_k, + "reranking_enable": False, + "rewrite_enable": False, + "query_expansion_enable": False, + "related_search_enable": False, + "multi_modal": True, + }) + body = build_payload(config, normalized_query, [str(kds_id)]) + body["metadata_condition"] = { + "logical_operator": "or", + "conditions": [ + { + "name": "original_file_name", + "comparison_operator": "=", + "value": normalized_file_name, + } + ], + } + + result = _request_aidp_json( + server_url, + api_key, + "POST", + _get_retrieval_path(tenant_id), + "chunk search", + json_body=body, + ) + records = result.get("result") if isinstance(result, dict) else None + if not isinstance(records, list): + raise AppException( + ErrorCode.AIDP_RESPONSE_ERROR, + "Unexpected AIDP chunk search response format", + ) + + chunks: List[Dict[str, Any]] = [] + for hit in records: + if not isinstance(hit, dict): + continue + title = hit.get("title") + if isinstance(title, str) and title.strip() and title.strip() != normalized_file_name: + # Defensive check: the upstream filter is authoritative, but do not + # surface a different file if it is ignored or unavailable. + continue + chunk_id = _normalize_chunk_id(hit.get("id")) + if not isinstance(chunk_id, int) or chunk_id < 0: + continue + raw_pages = hit.get("pages", hit.get("page_ids")) + pages = [] + if isinstance(raw_pages, list): + normalized_pages = [_normalize_chunk_id(page) for page in raw_pages] + pages = [ + page + for page in normalized_pages + if isinstance(page, int) and not isinstance(page, bool) + ] + metadata = hit.get("metadata") + chunks.append({ + "chunk_id": chunk_id, + "chunk_text": str(hit.get("text") or hit.get("chunk_text") or ""), + "chunk_type": str(hit.get("chunk_type") or "TEXT").upper(), + "file_name": str(title or normalized_file_name), + "file_url": str(hit.get("file_url") or ""), + "page_ids": pages, + "metadata": { + key: value + for key, value in (metadata if isinstance(metadata, dict) else {}).items() + if not key.startswith("_") + }, + "score": hit.get("score"), + }) + + return { + "value": chunks, + "total_count": len(chunks), + "has_more": False, + "total_reliable": True, + } + + def fusion_search_impl( server_url: str, api_key: str, diff --git a/frontend/ext_components/aidp/pages/knowledge-files/AidpChunkCard.tsx b/frontend/ext_components/aidp/pages/knowledge-files/AidpChunkCard.tsx new file mode 100644 index 0000000000..a4ccb08d1e --- /dev/null +++ b/frontend/ext_components/aidp/pages/knowledge-files/AidpChunkCard.tsx @@ -0,0 +1,204 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import React, { useLayoutEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, Checkbox, Tooltip } from "antd"; +import { DeleteOutlined, EditOutlined } from "@ant-design/icons"; + +import type { AidpChunk } from "@/ext_components/aidp/shared/types/chunks"; +import { + isAidpChunkEditable, + normalizeAidpChunkType, +} from "@/ext_components/aidp/shared/utils/aidpChunkType"; +import styles from "./AidpSemanticChunks.module.css"; + +interface AidpChunkCardProps { + chunk: AidpChunk; + /** One-based position across the whole file, shown with a human-readable label. */ + index: number; + selected: boolean; + canEdit: boolean; + onSelectChange: (selected: boolean) => void; + onOpen: () => void; + onEdit: () => void; + onDelete: () => void; +} + +const formatMetadataValue = (value: unknown): string => { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +}; + +/** + * One semantic chunk summary. Every displayed value comes from AIDP; a field + * the response omitted is simply not rendered. + */ +const AidpChunkCard: React.FC = ({ + chunk, + index, + selected, + canEdit, + onSelectChange, + onOpen, + onEdit, + onDelete, +}) => { + const { t } = useTranslation(); + const text = typeof chunk.chunk_text === "string" ? chunk.chunk_text : ""; + const textRef = useRef(null); + const [expanded, setExpanded] = useState(false); + const [textOverflowing, setTextOverflowing] = useState(false); + const editable = isAidpChunkEditable(chunk); + const chunkType = normalizeAidpChunkType(chunk.chunk_type); + const metadataEntries = Object.entries(chunk.metadata ?? {}).filter( + ([key, value]) => + key.trim().length > 0 && + value !== null && + value !== undefined && + formatMetadataValue(value) !== "" + ); + + // Show the expand control only while the clamped preview actually hides + // text. The length fallback covers browsers that do not expose a useful + // scrollHeight for line-clamped boxes. + useLayoutEffect(() => { + if (expanded) return; + const element = textRef.current; + if (!element) return; + const check = () => { + setTextOverflowing( + element.scrollHeight > element.clientHeight + 1 || text.length > 180 + ); + }; + check(); + const observer = new ResizeObserver(check); + observer.observe(element); + return () => observer.disconnect(); + }, [expanded, text]); + + return ( +
+ {canEdit && ( + + onSelectChange(event.target.checked)} + aria-label={t("aidpKnowledge.chunkSelectOne", { + index, + })} + /> + + )} +
+
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onOpen(); + } + }} + > + + + {t("aidpKnowledge.chunkSequence", { index })} + + + {t("aidpKnowledge.chunkId")}: {chunk.chunk_id} + + + {t("aidpKnowledge.chunkChars")}: {text.length} + + {chunkType && ( + {chunkType} + )} + +
+ {text || t("aidpKnowledge.chunkTextEmpty")} +
+
+ {textOverflowing && ( + + )} + {metadataEntries.length > 0 && ( +
+ + {t("aidpKnowledge.chunkMetadata")} + + {metadataEntries.map(([key, value]) => ( + + {key}: {formatMetadataValue(value)} +
+ } + > + + {key}: {formatMetadataValue(value)} + + + ))} +
+ )} +
+ {canEdit && ( +
+ {editable && ( + +
+ )} + + ); +}; + +export default AidpChunkCard; diff --git a/frontend/ext_components/aidp/pages/knowledge-files/AidpChunkDeleteModal.tsx b/frontend/ext_components/aidp/pages/knowledge-files/AidpChunkDeleteModal.tsx new file mode 100644 index 0000000000..6a0f7c4157 --- /dev/null +++ b/frontend/ext_components/aidp/pages/knowledge-files/AidpChunkDeleteModal.tsx @@ -0,0 +1,81 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Button, Modal } from "antd"; + +import styles from "./AidpSemanticChunks.module.css"; + +interface AidpChunkDeleteModalProps { + open: boolean; + /** Number of chunks in the pending removal request. */ + count: number; + submitting: boolean; + onCancel: () => void; + onConfirm: () => void; +} + +/** + * Removal confirmation. + * + * The dialog only states what the UI itself does (the chunks disappear from + * this file's list). AIDP has not documented when the vector, full-text and + * graph indexes catch up, so the copy does not promise an immediate or + * complete effect on retrieval. + */ +const AidpChunkDeleteModal: React.FC = ({ + open, + count, + submitting, + onCancel, + onConfirm, +}) => { + const { t } = useTranslation(); + + return ( + +
+

+ {t("aidpKnowledge.chunkDeleteTitle")} +

+

+ {t("aidpKnowledge.chunkDeleteBody", { count })} +

+

+ {t("aidpKnowledge.chunkDeleteImpact")} +

+
+ + +
+
+
+ ); +}; + +export default AidpChunkDeleteModal; diff --git a/frontend/ext_components/aidp/pages/knowledge-files/AidpChunkDetailDrawer.tsx b/frontend/ext_components/aidp/pages/knowledge-files/AidpChunkDetailDrawer.tsx new file mode 100644 index 0000000000..c1b1a13eb0 --- /dev/null +++ b/frontend/ext_components/aidp/pages/knowledge-files/AidpChunkDetailDrawer.tsx @@ -0,0 +1,335 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import React, { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Alert, Button, Drawer, Spin, Tooltip } from "antd"; +import { + CloseOutlined, + DownloadOutlined, + EditOutlined, + LeftOutlined, + RightOutlined, +} from "@ant-design/icons"; + +import type { AidpChunk } from "@/ext_components/aidp/shared/types/chunks"; +import { useAidpChunkDetail } from "./hooks/useAidpFileChunks"; +import { getAidpErrorMessage } from "@/ext_components/aidp/shared/utils/aidpErrorUtils"; +import { + isAidpChunkEditable, + normalizeAidpChunkType, +} from "@/ext_components/aidp/shared/utils/aidpChunkType"; +import styles from "./AidpSemanticChunks.module.css"; + +interface AidpChunkDetailDrawerProps { + open: boolean; + kdsId: string; + chunkId: number | null; + /** Chunks on the current page; they only drive previous/next navigation. */ + pageChunks: AidpChunk[]; + offset: number; + canEdit: boolean; + onClose: () => void; + onNavigate: (chunkId: number) => void; + onEdit: (chunk: AidpChunk) => void; +} + +const formatMetadataValue = (value: unknown): string => { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +}; + +/** + * TABLE chunks currently carry only text. When AIDP formats it as pipe- + * separated rows, render a real table; otherwise fall back to the raw text. + */ +const parsePipeTable = ( + text: string +): { caption: string; rows: string[][] } | null => { + const lines = text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const tableLines = lines.filter((line) => line.includes("|")); + if (tableLines.length < 2) return null; + const rows = tableLines.map((line) => + line.split("|").map((cell) => cell.trim()) + ); + const columns = Math.max(...rows.map((row) => row.length)); + if (columns < 2) return null; + const caption = lines.filter((line) => !line.includes("|")).join(" "); + return { + caption, + rows: rows.map((row) => [ + ...row, + ...Array.from({ length: columns - row.length }, () => ""), + ]), + }; +}; + +/** + * Read-only chunk detail in the same right-side drawer as the edit form. + * + * The drawer keeps the list mounted behind it. Only fields AIDP actually + * returned are shown: `updated_at`, labels and entities have no source in the + * chunk contract, so no placeholder is rendered for them. + */ +const AidpChunkDetailDrawer: React.FC = ({ + open, + kdsId, + chunkId, + pageChunks, + offset, + canEdit, + onClose, + onNavigate, + onEdit, +}) => { + const { t } = useTranslation(); + const detailQuery = useAidpChunkDetail(kdsId, open ? chunkId : null); + const chunk = detailQuery.data; + + const position = useMemo( + () => pageChunks.findIndex((item) => item.chunk_id === chunkId), + [pageChunks, chunkId] + ); + const sequence = position >= 0 ? offset + position + 1 : null; + const previousChunk = position > 0 ? pageChunks[position - 1] : null; + const nextChunk = + position >= 0 && position < pageChunks.length - 1 + ? pageChunks[position + 1] + : null; + + const metadataEntries = useMemo( + () => + Object.entries(chunk?.metadata ?? {}).filter( + ([key, value]) => + key.trim().length > 0 && + value !== null && + value !== undefined && + formatMetadataValue(value) !== "" + ), + [chunk?.metadata] + ); + const text = typeof chunk?.chunk_text === "string" ? chunk.chunk_text : ""; + const chunkType = normalizeAidpChunkType(chunk?.chunk_type); + const tableData = chunkType === "TABLE" ? parsePipeTable(text) : null; + const editable = isAidpChunkEditable(chunk); + + // Export only the detail response, without adding fields the UI does not + // have. The file name follows the chunk id so downloads remain distinguishable. + const handleExport = useCallback(() => { + if (!chunk) return; + const blob = new Blob([JSON.stringify(chunk, null, 2)], { + type: "application/json;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + try { + link.href = url; + link.download = `chunk-${chunk.chunk_id}.json`; + document.body.appendChild(link); + link.click(); + } finally { + link.remove(); + URL.revokeObjectURL(url); + } + }, [chunk]); + + return ( + + {open && ( +
+
+

+ {sequence !== null + ? `#${sequence}` + : t("aidpKnowledge.chunkDetailTitle")} +

+
+ +
+
+ + {detailQuery.isError ? ( +
+ void detailQuery.refetch()} + > + {t("aidpKnowledge.chunksRetry")} + + } + /> +
+ ) : detailQuery.isPending ? ( +
+
+ +
+
+ ) : ( +
+
+
+ + {t("aidpKnowledge.chunkId")} + + + {chunk?.chunk_id ?? t("aidpKnowledge.chunkUnknown")} + +
+
+ + {t("aidpKnowledge.chunkChars")} + + {text.length} +
+
+ + {t("aidpKnowledge.chunkUpdatedAt")} + + + {chunk?.updated_at || "—"} + +
+
+ + {tableData ? ( +
+ {tableData.caption && ( +
+ {tableData.caption} +
+ )} +
+ + + + {tableData.rows[0].map((cell, index) => ( + + ))} + + + + {tableData.rows.slice(1).map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
{cell}
{cell}
+
+
+ ) : ( +
+ {text || t("aidpKnowledge.chunkTextEmpty")} +
+ )} + +
+

+ {metadataEntries.length > 0 + ? t("aidpKnowledge.chunkMetadataCount", { + count: metadataEntries.length, + }) + : t("aidpKnowledge.chunkMetadata")} +

+ {metadataEntries.length > 0 ? ( +
+ {metadataEntries.map(([key, value]) => ( + + {key}: {formatMetadataValue(value)} + + ))} +
+ ) : ( +
+ {t("aidpKnowledge.chunkNoMetadata")} +
+ )} +
+
+ )} + +
+ +
+
+ )} +
+ ); +}; + +export default AidpChunkDetailDrawer; diff --git a/frontend/ext_components/aidp/pages/knowledge-files/AidpChunkDrawer.tsx b/frontend/ext_components/aidp/pages/knowledge-files/AidpChunkDrawer.tsx new file mode 100644 index 0000000000..32e5e2e17a --- /dev/null +++ b/frontend/ext_components/aidp/pages/knowledge-files/AidpChunkDrawer.tsx @@ -0,0 +1,218 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import React, { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, Drawer, Input } from "antd"; +import { CloseOutlined } from "@ant-design/icons"; + +import type { AidpChunk } from "@/ext_components/aidp/shared/types/chunks"; +import styles from "./AidpSemanticChunks.module.css"; + +export const AIDP_CHUNK_TEXT_MAX_LENGTH = 10240; + +export type AidpChunkDrawerMode = "create" | "edit"; + +interface AidpChunkDrawerProps { + open: boolean; + mode: AidpChunkDrawerMode; + /** The chunk being edited; null when adding a new one. */ + chunk: AidpChunk | null; + submitting: boolean; + onClose: () => void; + /** Rejecting keeps the drawer open with the entered text intact. */ + onSubmit: (text: string) => Promise; +} + +type AidpChunkFormProps = Omit; + +/** + * Text form for one chunk. + * + * It is mounted only while the drawer is open and keyed by the target chunk, + * so every target starts with its own text and a failed submit keeps the input. + */ +const AidpChunkForm: React.FC = ({ + mode, + chunk, + submitting, + onClose, + onSubmit, +}) => { + const { t } = useTranslation(); + const [text, setText] = useState( + mode === "edit" ? (chunk?.chunk_text ?? "") : "" + ); + const [focused, setFocused] = useState(false); + const [touched, setTouched] = useState(false); + + const trimmed = text.trim(); + const isEmpty = trimmed.length === 0; + const isTooLong = text.length > AIDP_CHUNK_TEXT_MAX_LENGTH; + const canSubmit = !isEmpty && !isTooLong && !submitting; + // UCD values: 1px border, 4px radius, #DFDFDF default and #0067D1 focus. + // Applied inline because Ant Design's outlined input draws its border with a + // box-shadow, which overrides a same-specificity CSS module rule. + const textAreaBorderColor = isTooLong + ? "#e02128" + : focused + ? "#0067d1" + : "#dfdfdf"; + + const handleSubmit = useCallback(async () => { + if (!canSubmit) return; + try { + await onSubmit(trimmed); + } catch { + // The parent reports the failure; keeping the text lets the user retry. + } + }, [canSubmit, onSubmit, trimmed]); + + return ( +
+
+
+

+ {mode === "create" + ? t("aidpKnowledge.chunkCreateTitle") + : t("aidpKnowledge.chunkEditTitle")} +

+
+
+ {mode === "edit" && ( +
+
+ + {t("aidpKnowledge.chunkId")} + +
+
+ {chunk?.chunk_id ?? t("aidpKnowledge.chunkUnknown")} +
+
+ )} +
+
+ + {t("aidpKnowledge.chunkText")} + + +
+ { + setText(event.target.value); + setTouched(true); + }} + onFocus={() => setFocused(true)} + onBlur={() => { + setFocused(false); + setTouched(true); + }} + placeholder={t("aidpKnowledge.chunkTextPlaceholder")} + autoSize={{ minRows: 6, maxRows: 14 }} + aria-label={t("aidpKnowledge.chunkText")} + aria-invalid={isTooLong || isEmpty} + aria-required="true" + disabled={submitting} + /> +
+ + {mode === "create" + ? t("aidpKnowledge.chunkCreateHint") + : t("aidpKnowledge.chunkEditVectorHint")} + + + {text.length}/{AIDP_CHUNK_TEXT_MAX_LENGTH} + +
+ {isTooLong && ( +

+ {t("aidpKnowledge.chunkTextTooLong", { + max: AIDP_CHUNK_TEXT_MAX_LENGTH, + })} +

+ )} + {!isTooLong && touched && isEmpty && ( +

+ {t("aidpKnowledge.chunkTextRequired")} +

+ )} +
+
+
+
+ + +
+
+ ); +}; + +/** + * Add or edit one chunk. + * + * Only the text is writable: the AIDP contract accepts `chunk_text` alone for + * an update. The prototype's metadata/entity editors have no backend write + * capability, so this version does not render those sections. + */ +const AidpChunkDrawer: React.FC = ({ + open, + mode, + chunk, + submitting, + onClose, + onSubmit, +}) => ( + + {open && ( + + )} + +); + +export default AidpChunkDrawer; diff --git a/frontend/ext_components/aidp/pages/knowledge-files/AidpFileDetail.module.css b/frontend/ext_components/aidp/pages/knowledge-files/AidpFileDetail.module.css new file mode 100644 index 0000000000..d15fd73d8c --- /dev/null +++ b/frontend/ext_components/aidp/pages/knowledge-files/AidpFileDetail.module.css @@ -0,0 +1,118 @@ +/* Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. */ + +/* + * AIDP file detail shell. + * + * Colour, border and radius values come from the verified UCD record: + * left preview surface #F3F3F3 with a #DFDFDF divider, 16px outer radius, + * selected tab #0067D1, unselected tab #777777, tab row border #DFDFDF. + * The tab text token is Body/font-body-3 (16px / 24px, weight 400). + */ +.shell { + --aidp-detail-border: #dfdfdf; + --aidp-detail-primary: #0067d1; + --aidp-detail-muted: #777777; + --aidp-detail-text: #191919; + display: flex; + min-height: 0; + height: 100%; + flex-direction: column; + font-family: "HarmonyOS Sans SC", "Microsoft YaHei", sans-serif; + overflow: hidden; +} + +.split { + min-height: 0; + flex: 1; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + border: 1px solid var(--aidp-detail-border); + border-radius: 16px; + background: #ffffff; +} + +.previewPane { + display: none; + min-width: 0; + flex: 1; + overflow: hidden; + border-right: 1px solid var(--aidp-detail-border); + background: #f3f3f3; +} + +@media (min-width: 1024px) { + .split { + grid-template-columns: minmax(0, 1fr) minmax(0, 48.67%); + } + + .previewPane { + display: flex; + flex-direction: column; + } +} + +.panelPane { + display: flex; + width: 100%; + min-width: 0; + min-height: 0; + flex-direction: column; + background: #ffffff; +} + +@media (min-width: 1024px) { + .panelPane { + grid-column: 2; + grid-row: 1 / span 2; + } +} + +.tabRow { + display: flex; + flex: 0 0 auto; + box-sizing: border-box; + gap: 32px; + margin: 0 12px; + min-height: 52px; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid var(--aidp-detail-border); +} + +.tab { + margin: 0; + color: var(--aidp-detail-primary); + font-size: 16px; + line-height: 24px; + font-weight: 400; +} + +.readerHeader { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: #fafafa; + border-bottom: 1px solid #dfdfdf; + flex-shrink: 0; +} +.fileName { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #191919; + font-size: 14px; +} +.readerContent { + flex: 1; + min-height: 0; + padding: 20px; + background: #f3f3f3; +} +.readerContent > div { + background: white; + box-shadow: 0 2px 8px #0000000a; +} diff --git a/frontend/ext_components/aidp/pages/knowledge-files/AidpFileDetailView.tsx b/frontend/ext_components/aidp/pages/knowledge-files/AidpFileDetailView.tsx new file mode 100644 index 0000000000..c5432436a7 --- /dev/null +++ b/frontend/ext_components/aidp/pages/knowledge-files/AidpFileDetailView.tsx @@ -0,0 +1,172 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { App, Button, ConfigProvider, Tooltip } from "antd"; +import { + ArrowLeftOutlined, + DownloadOutlined, + ExpandOutlined, +} from "@ant-design/icons"; + +import type { AidpDocumentItem } from "@/ext_components/aidp/shared/types/management"; +import aidpKnowledgeService from "@/ext_components/aidp/shared/services/aidpKnowledgeService"; +import { FilePreviewDrawer } from "@/components/common/filePreviewDrawer"; +import { getAidpDownloadFilename } from "@/ext_components/aidp/shared/utils/aidpDownloadFilename"; +import { getAidpErrorMessage } from "@/ext_components/aidp/shared/utils/aidpErrorUtils"; +import log from "@/lib/logger"; +import AidpSemanticChunksPanel from "./AidpSemanticChunksPanel"; +import styles from "./AidpFileDetail.module.css"; + +interface AidpFileDetailViewProps { + kdsId: string; + file: AidpDocumentItem; + canEdit: boolean; + onBack: () => void; +} + +/** + * File detail for one ingested AIDP document. + * + * The left pane reuses the existing preview stream; the right pane hosts the semantic chunks. + */ +const AidpFileDetailView: React.FC = ({ + kdsId, + file, + canEdit, + onBack, +}) => { + const { t } = useTranslation(); + const { message } = App.useApp(); + const [downloading, setDownloading] = useState(false); + const [fullscreenOpen, setFullscreenOpen] = useState(false); + const previewUrl = aidpKnowledgeService.getPreviewUrl(kdsId, file.file_uuid); + + const handleDownload = async () => { + setDownloading(true); + try { + const response = await aidpKnowledgeService.downloadDoc( + kdsId, + file.file_uuid + ); + const url = URL.createObjectURL(await response.blob()); + const link = document.createElement("a"); + try { + link.href = url; + link.download = getAidpDownloadFilename(response.headers, file); + document.body.appendChild(link); + link.click(); + } finally { + link.remove(); + URL.revokeObjectURL(url); + } + } catch (error) { + log.error("Failed to download AIDP document", { + kdsId, + fileUuid: file.file_uuid, + error, + }); + message.error( + getAidpErrorMessage(error, t, t("aidpKnowledge.downloadFailed")) + ); + } finally { + setDownloading(false); + } + }; + + return ( + +
+
+
+
+
+
+ {/* The preview keeps its own loading/error/format handling; inline + mode only removes the drawer chrome so it fills this column. */} + undefined} + /> +
+
+
+
+

{t("aidpKnowledge.chunksTab")}

+
+ +
+
+ + {fullscreenOpen && ( + setFullscreenOpen(false)} + /> + )} +
+
+ ); +}; + +export default AidpFileDetailView; diff --git a/frontend/ext_components/aidp/pages/knowledge-files/AidpKnowledgeFilesPage.tsx b/frontend/ext_components/aidp/pages/knowledge-files/AidpKnowledgeFilesPage.tsx index f62adb460b..f7a8722832 100644 --- a/frontend/ext_components/aidp/pages/knowledge-files/AidpKnowledgeFilesPage.tsx +++ b/frontend/ext_components/aidp/pages/knowledge-files/AidpKnowledgeFilesPage.tsx @@ -55,7 +55,7 @@ import AidpPagination from "@/ext_components/aidp/shared/components/AidpPaginati import AidpImportDrawer from "@/ext_components/aidp/features/file-import/AidpImportDrawer"; import AidpUpdateKbModal from "@/ext_components/aidp/pages/knowledge-files/AidpUpdateKbModal"; import AidpGroupNamesDisplay from "@/ext_components/aidp/shared/components/AidpGroupNamesDisplay"; -import { FilePreviewDrawer } from "@/components/common/filePreviewDrawer"; +import AidpFileDetailView from "@/ext_components/aidp/pages/knowledge-files/AidpFileDetailView"; import AidpResizableTitle from "@/ext_components/aidp/pages/knowledge-list/AidpResizableTitle"; import { AidpDetailField } from "@/ext_components/aidp/pages/knowledge-files/AidpKnowledgeDetailInformation"; import AidpKnowledgeTasksTab from "@/ext_components/aidp/pages/knowledge-files/AidpKnowledgeTasksTab"; @@ -115,7 +115,9 @@ const AidpKnowledgeFilesPage: React.FC = ({ "metadata" | "permissions" | null >(null); const [importOpen, setImportOpen] = useState(false); - const [previewFile, setPreviewFile] = useState(null); + // The file detail replaces the table while open: it shows the preview and + // the semantic chunk panel for one file. + const [detailFile, setDetailFile] = useState(null); const [fileKeyword, setFileKeyword] = useState(""); const [fileParams, setFileParams] = useState({ page: 1, @@ -128,7 +130,11 @@ const AidpKnowledgeFilesPage: React.FC = ({ filePage, filePageSize, fileParams.keyword, - pollingPaused || importOpen || deleteConfirmOpen || editingKbMode !== null + pollingPaused || + importOpen || + deleteConfirmOpen || + editingKbMode !== null || + detailFile !== null ); // Memoized so callbacks depending on `files` keep a stable reference when // the query has not produced a new page. @@ -400,11 +406,11 @@ const AidpKnowledgeFilesPage: React.FC = ({ // file clicks its name, while the action column keeps the operations // that change the file. render: (value: string, record) => ( - + @@ -514,6 +520,23 @@ const AidpKnowledgeFilesPage: React.FC = ({ const privateScope = detail.ingroup_permission === "PRIVATE"; + if (detailFile) { + return ( + { + setDetailFile(null); + // The list paused polling while the detail was open; refresh once on + // return so ingestion changes are reflected. + void refreshFiles(); + }} + /> + ); + } + return (
= ({ void refreshFiles(); }} /> - - {/* Preview opens AIDP's own stream as a full-screen page with a back - arrow. `rawStream` tells the preview that AIDP decides the served - format (it converts anything it cannot render to PDF), so the renderer - comes from the `X-File-Name` response header. */} - {previewFile && ( - setPreviewFile(null)} - /> - )}
); }; diff --git a/frontend/ext_components/aidp/pages/knowledge-files/AidpSemanticChunks.module.css b/frontend/ext_components/aidp/pages/knowledge-files/AidpSemanticChunks.module.css new file mode 100644 index 0000000000..02b47c64e3 --- /dev/null +++ b/frontend/ext_components/aidp/pages/knowledge-files/AidpSemanticChunks.module.css @@ -0,0 +1,571 @@ +/* Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. */ + +/* + * Semantic chunk panel. + * + * Verified UCD values used here: + * - selected/active blue #0067D1, unselected grey #777777, + * - hairline border #DFDFDF, body text #191919, secondary #595959, + * - body text token Body/font-body-2 (14px / 22px, weight 400), + * - edit drawer width 960px with 24px inner padding and 20px / 28px Medium + * headline, field label 14px / 22px, required marker #E02128, + * - multiline input 1px border, 4px radius, blue focus border, + * - delete dialog width 608px, 8px radius, 20px / 24px padding, 20px / 28px + * Medium title, 14px / 22px body text, 32px high buttons with 4px radius + * and 8px gap. + * Card geometry and the counter typography were not individually verified in + * the UCD record; they use the same tokens as the verified surfaces. + */ +.panel { + --chunk-primary: #0067d1; + --chunk-text: #191919; + --chunk-secondary: #595959; + --chunk-muted: #777777; + --chunk-border: #dfdfdf; + --chunk-error: #e02128; + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; +} + +.toolbar { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + gap: 6px; + padding: 10px 0; +} + +.toolbarLeft { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} +.searchInput { + flex: 1 1 280px; + width: auto; + min-width: 220px; +} + +.list { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 12px; + overflow-y: auto; + padding: 2px 1px 8px; +} + +.card { + display: flex; + align-items: flex-start; + gap: 12px; + box-sizing: border-box; + width: 100%; + padding: 12px 16px; + border: 1px solid var(--chunk-border); + border-radius: 8px; + background: #ffffff; + text-align: left; +} + +.card:hover { + border-color: var(--chunk-primary); +} + +.cardSelected { + border-color: var(--chunk-primary); +} + +.cardBody { + min-width: 0; + flex: 1; +} + +/* The card body is the button that opens the detail; the checkbox and the + inline actions stay outside it so no interactive control is nested. */ +.cardOpenButton { + display: block; + box-sizing: border-box; + width: 100%; + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.cardOpenButton:focus-visible { + outline: 2px solid var(--chunk-primary); + outline-offset: 2px; + border-radius: 4px; +} + +.cardMetaRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 16px; +} + +.cardIndex { + color: var(--chunk-text); + font-size: 14px; + font-weight: 500; + line-height: 22px; +} + +.cardMeta { + color: var(--chunk-secondary); + font-size: 12px; + line-height: 20px; +} +.cardTypeBadge { + padding: 0 6px; + border-radius: 3px; + background: #f0f0f0; + color: #777777; + font-size: 12px; + line-height: 20px; +} + +.cardText { + display: -webkit-box; + overflow: hidden; + margin: 6px 0 0; + color: var(--chunk-text); + font-size: 14px; + line-height: 22px; + white-space: pre-wrap; + word-break: break-word; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; +} +.cardTextExpanded { + display: block; + overflow: visible; + -webkit-line-clamp: unset; +} +.cardExpandButton { + margin-top: 4px; + padding: 0; + border: 0; + background: none; + color: var(--chunk-primary); + font-size: 12px; + line-height: 20px; + cursor: pointer; +} +.cardExpandButton:hover { + text-decoration: underline; +} +.cardTooltipContent { + max-width: 480px; + max-height: 260px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} +.whiteTooltip :global(.ant-tooltip-inner) { + color: #191919; + border: 1px solid #dfdfdf; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); +} +.whiteTooltip :global(.ant-tooltip-arrow)::before { + background: #ffffff; +} + +.cardActions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 2px; +} + +.emptyState { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + padding: 32px 0; +} + +.pagination { + flex: 0 0 auto; + border-top: 1px solid #f3f3f3; + padding: 12px 0; +} + +/* Edit / create drawer ---------------------------------------------------- */ + +.drawerTitleRow { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + padding: 20px 24px 0; +} + +.drawerTitle { + margin: 0; + color: var(--chunk-text); + font-family: "HarmonyOS Sans SC", "Microsoft YaHei", sans-serif; + font-size: 20px; + font-weight: 500; + line-height: 28px; +} + +.drawerContent { + display: flex; + flex-direction: column; + gap: 16px; + padding: 0 24px 24px; +} + +.fieldLabelRow { + display: flex; + align-items: baseline; + gap: 4px; + margin-bottom: 8px; +} + +.fieldLabel { + color: var(--chunk-text); + font-family: "HarmonyOS Sans SC", "Microsoft YaHei", sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 22px; +} + +.requiredMark { + color: var(--chunk-error); + font-family: "HarmonyOS Sans SC", "Microsoft YaHei", sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 22px; +} + +/* The input border is applied inline: Ant Design's outlined variant draws its + border with a box-shadow that a same-specificity rule cannot override. */ +.textArea { + box-sizing: border-box; + width: 100%; + background: #ffffff; + color: var(--chunk-text); + font-size: 14px; + line-height: 22px; +} + +.counterRow { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-top: 4px; +} + +.counter { + flex: 0 0 auto; + color: var(--chunk-muted); + font-size: 12px; + line-height: 20px; +} + +.errorText { + margin: 4px 0 0; + color: var(--chunk-error); + font-size: 12px; + line-height: 20px; +} + +.drawerFooter { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 24px 20px; + border-top: 1px solid #f3f3f3; +} + +/* Delete confirmation ----------------------------------------------------- */ + +.deleteDialog { + box-sizing: border-box; + width: 100%; + max-width: 608px; + padding: 20px 24px; + border-radius: 8px; + background: #ffffff; +} + +.deleteTitle { + margin: 0; + color: var(--chunk-text); + font-family: "HarmonyOS Sans SC", "Microsoft YaHei", sans-serif; + font-size: 20px; + font-weight: 500; + line-height: 28px; +} + +.deleteBody { + margin: 16px 0 0; + color: var(--chunk-text); + font-family: "HarmonyOS Sans SC", "Microsoft YaHei", sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 22px; +} + +.deleteImpact { + margin: 4px 0 0; + color: var(--chunk-secondary); + font-size: 14px; + line-height: 22px; +} + +.deleteActions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} + +/* Chunk detail ------------------------------------------------------------ */ + +.detailMetaLabel { + display: block; + color: var(--chunk-secondary); + font-size: 12px; + line-height: 20px; +} + +.detailMetaValue { + color: var(--chunk-text); + font-size: 14px; + line-height: 22px; + word-break: break-all; +} + +.metadataList { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.metadataTag { + display: inline-flex; + max-width: 100%; + align-items: center; + padding: 0 6px; + border-radius: 4px; + background: #f3f3f3; + color: var(--chunk-text); + font-size: 12px; + line-height: 20px; + word-break: break-all; +} + +.panel { + padding: 0 12px; +} +.toolbar { + min-height: 72px; +} +.card { + position: relative; + flex-shrink: 0; + padding: 16px; + gap: 10px; + border-radius: 6px; + transition: + border-color 150ms, + background 150ms; +} +.cardSelected { + background: #f5f8ff; + border-color: #99bde7; +} +.cardMetaRow { + padding-right: 52px; + gap: 4px 12px; +} +.cardActions { + position: absolute; + right: 10px; + top: 10px; + color: #777; +} +.cardText { + margin-top: 12px; +} +.cardMetadataRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-top: 10px; +} +.cardMetadataLabel { + color: #777; + font-size: 12px; + line-height: 20px; +} +.cardMetadataTag { + display: inline-flex; + max-width: 100%; + overflow: hidden; + align-items: center; + padding: 0 6px; + border-radius: 4px; + background: #f5f5f5; + color: #595959; + font-size: 12px; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} +.drawerContent { + padding-top: 24px; + gap: 24px; +} +.readonlyInput { + padding: 5px 12px; + border: 1px solid #dfdfdf; + border-radius: 4px; + background: #f5f5f5; + color: #777; +} +.formHint { + min-width: 0; + flex: 1; + margin: 0; + color: #999999; + font-size: 12px; + line-height: 20px; +} +.chunkDrawer { + --chunk-text: #191919; + --chunk-muted: #777; + --chunk-error: #e02128; +} + +/* Read-only chunk drawer -------------------------------------------------- */ + +.detailDrawerBody { + min-height: 0; + flex: 1; + overflow-y: auto; + padding: 20px 24px 24px; +} + +.detailMetaGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 16px; + padding-bottom: 16px; + border-bottom: 1px solid #f0f0f0; +} + +.detailMetaItem { + min-width: 0; +} + +.detailTextBlock { + margin-top: 16px; + color: var(--chunk-text); + font-size: 14px; + line-height: 22px; + white-space: pre-wrap; + word-break: break-word; +} + +.chunkTableWrap { + margin-top: 16px; +} +.chunkTableCaption { + margin-bottom: 8px; + color: var(--chunk-text); + font-size: 14px; + line-height: 22px; +} +.chunkTableScroll { + max-width: 100%; + overflow-x: auto; +} +.chunkTable { + width: 100%; + border-collapse: collapse; + color: var(--chunk-text); + font-size: 13px; + line-height: 20px; +} +.chunkTable th, +.chunkTable td { + padding: 8px 12px; + border: 1px solid #e5e5e5; + text-align: left; + vertical-align: top; + white-space: nowrap; +} +.chunkTable th { + background: #fafafa; + color: #595959; + font-weight: 500; +} + +.detailSection { + margin-top: 20px; +} + +.detailSectionTitle { + margin: 0 0 8px; + color: #777777; + font-size: 12px; + font-weight: 400; + line-height: 20px; +} + +.detailDrawerFooter { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + padding: 12px 24px; + border-top: 1px solid #f3f3f3; +} + +.detailNavButton { + width: 32px; + height: 32px; +} +@media (max-width: 767px) { + .chunkDrawer :global(.ant-drawer-content-wrapper) { + width: 100vw; + max-width: 100vw; + width: 100vw !important; + } + .toolbarLeft { + gap: 6px; + } +} + +.toolbarActions { + display: flex; + align-items: center; + margin-left: auto; + gap: 6px; +} +.moreLabel { + display: inline-flex; + align-items: center; + gap: 4px; +} +.moreLabel :global(.anticon) { + font-size: 10px; +} diff --git a/frontend/ext_components/aidp/pages/knowledge-files/AidpSemanticChunksPanel.tsx b/frontend/ext_components/aidp/pages/knowledge-files/AidpSemanticChunksPanel.tsx new file mode 100644 index 0000000000..a92850696e --- /dev/null +++ b/frontend/ext_components/aidp/pages/knowledge-files/AidpSemanticChunksPanel.tsx @@ -0,0 +1,538 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; +import { + Alert, + App, + Button, + Checkbox, + Dropdown, + Empty, + Input, + Skeleton, +} from "antd"; +import { + DownOutlined, + PlusOutlined, + ReloadOutlined, + SearchOutlined, +} from "@ant-design/icons"; + +import type { AidpChunk } from "@/ext_components/aidp/shared/types/chunks"; +import aidpKnowledgeService from "@/ext_components/aidp/shared/services/aidpKnowledgeService"; +import { getAidpErrorMessage } from "@/ext_components/aidp/shared/utils/aidpErrorUtils"; +import { isAidpChunkEditable } from "@/ext_components/aidp/shared/utils/aidpChunkType"; +import { useAidpQueryScope } from "@/ext_components/aidp/shared/hooks/useAidpQueryScope"; +import AidpPagination from "@/ext_components/aidp/shared/components/AidpPagination"; +import log from "@/lib/logger"; +import AidpChunkCard from "./AidpChunkCard"; +import AidpChunkDrawer, { type AidpChunkDrawerMode } from "./AidpChunkDrawer"; +import AidpChunkDetailDrawer from "./AidpChunkDetailDrawer"; +import AidpChunkDeleteModal from "./AidpChunkDeleteModal"; +import { + useAidpChunkSearch, + useAidpFileChunks, +} from "./hooks/useAidpFileChunks"; +import styles from "./AidpSemanticChunks.module.css"; + +const AIDP_CHUNK_PAGE_SIZE_DEFAULT = 20; +const AIDP_CHUNK_PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const; + +interface AidpSemanticChunksPanelProps { + kdsId: string; + fileUuid: string; + fileName: string; + canEdit: boolean; +} + +interface ChunkDrawerState { + mode: AidpChunkDrawerMode; + chunk: AidpChunk | null; +} + +/** + * Semantic chunk management for one file. + * + * List browsing uses AIDP's server-side paging and page-scoped selection. + * Content search uses a dedicated backend FusionSearch proxy constrained by + * the current file name; it does not reuse the model-facing aidp_search tool. + */ +const AidpSemanticChunksPanel: React.FC = ({ + kdsId, + fileUuid, + fileName, + canEdit, +}) => { + const { t } = useTranslation(); + const { message } = App.useApp(); + const queryClient = useQueryClient(); + const scope = useAidpQueryScope(); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(AIDP_CHUNK_PAGE_SIZE_DEFAULT); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [selectedIds, setSelectedIds] = useState([]); + const [detailId, setDetailId] = useState(null); + const [drawer, setDrawer] = useState(null); + // Set when editing was opened from the detail drawer: after save or cancel + // the read-only detail comes back instead of dropping the user on the list. + const [detailReturnId, setDetailReturnId] = useState(null); + const [deleteTargets, setDeleteTargets] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const chunksQuery = useAidpFileChunks(kdsId, fileUuid, page, pageSize); + const searchQuery = useAidpChunkSearch( + kdsId, + fileUuid, + fileName, + debouncedSearch + ); + const chunks = useMemo( + () => chunksQuery.data?.value ?? [], + [chunksQuery.data] + ); + const total = chunksQuery.data?.total_count ?? chunks.length; + const hasMore = chunksQuery.data?.has_more ?? false; + const totalReliable = chunksQuery.data?.total_reliable !== false; + const searchActive = debouncedSearch.trim().length > 0; + const activeQuery = searchActive ? searchQuery : chunksQuery; + const { refetch } = activeQuery; + const displayChunks = useMemo( + () => activeQuery.data?.value ?? [], + [activeQuery.data] + ); + const offset = searchActive ? 0 : (page - 1) * pageSize; + + // The input is local; only a settled query reaches AIDP FusionSearch. + useEffect(() => { + const timer = window.setTimeout(() => { + setDebouncedSearch(search); + }, 300); + return () => window.clearTimeout(timer); + }, [search]); + + const activeErrorMessage = activeQuery.isError + ? getAidpErrorMessage( + activeQuery.error, + t, + t( + searchActive + ? "aidpKnowledge.chunksSearchFailed" + : "aidpKnowledge.chunksLoadFailed" + ) + ) + : undefined; + + // Selection belongs to the visible page only: a new page or file starts + // with nothing selected instead of carrying an invisible range forward. + // A file switch remounts this panel, so only page changes are handled here. + const handlePageChange = useCallback((nextPage: number) => { + setSelectedIds([]); + setSearch(""); + setPage(nextPage); + }, []); + const handlePageSizeChange = useCallback((size: number) => { + setSelectedIds([]); + setSearch(""); + setPageSize(size); + setPage(1); + }, []); + + // Deleting the last chunks of a page must not leave the user on an empty + // page when earlier chunks still exist. + useEffect(() => { + if ( + !chunksQuery.isSuccess || + chunksQuery.isFetching || + chunks.length || + page <= 1 + ) + return; + const lastPage = totalReliable + ? Math.max(1, Math.ceil(total / pageSize)) + : page - 1; + // A microtask, not requestAnimationFrame: the correction must also run when + // the tab is in the background and no animation frames are being painted. + let cancelled = false; + queueMicrotask(() => { + if (cancelled) return; + setSelectedIds([]); + setPage(Math.min(page - 1, lastPage)); + }); + return () => { + cancelled = true; + }; + }, [ + chunksQuery.isSuccess, + chunksQuery.isFetching, + chunks.length, + page, + pageSize, + total, + totalReliable, + ]); + + const displayedChunkIds = useMemo( + () => displayChunks.map((chunk) => chunk.chunk_id), + [displayChunks] + ); + const allSelected = + displayedChunkIds.length > 0 && + displayedChunkIds.every((chunkId) => selectedIds.includes(chunkId)); + const someSelected = selectedIds.length > 0 && !allSelected; + + const toggleChunk = useCallback((chunkId: number, selected: boolean) => { + setSelectedIds((current) => + selected + ? current.includes(chunkId) + ? current + : [...current, chunkId] + : current.filter((id) => id !== chunkId) + ); + }, []); + + const toggleAll = useCallback( + (selected: boolean) => { + setSelectedIds(selected ? displayedChunkIds : []); + }, + [displayedChunkIds] + ); + + const refreshAfterWrite = useCallback( + async (reconcileSelection = false) => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: [...scope.key, "chunks", kdsId, fileUuid], + refetchType: "none", + }), + queryClient.invalidateQueries({ + queryKey: [...scope.key, "chunk-search", kdsId, fileUuid], + refetchType: "none", + }), + ]); + const refreshed = await refetch(); + if (reconcileSelection && refreshed.isSuccess) { + const remainingIds = new Set( + refreshed.data.value.map((chunk) => chunk.chunk_id) + ); + setSelectedIds((current) => + current.filter((id) => remainingIds.has(id)) + ); + } + }, + [fileUuid, kdsId, queryClient, refetch, scope.key] + ); + + const handleSave = useCallback( + async (text: string) => { + if (!drawer) return; + const targetId = drawer.chunk?.chunk_id; + setSubmitting(true); + try { + if (drawer.mode === "create") { + await aidpKnowledgeService.createChunk(kdsId, { + file_uuid: fileUuid, + chunk_text: text, + }); + } else if (targetId !== undefined) { + await aidpKnowledgeService.updateChunk(kdsId, targetId, { + chunk_text: text, + }); + } + } catch (error) { + message.error( + getAidpErrorMessage(error, t, t("aidpKnowledge.chunkSaveFailed")) + ); + throw error; + } finally { + if (targetId !== undefined) { + await queryClient.invalidateQueries({ + queryKey: [...scope.key, "chunk-detail", kdsId, targetId], + }); + } + await refreshAfterWrite(true); + setSubmitting(false); + } + + setDrawer(null); + setDetailReturnId(null); + if (detailReturnId !== null) setDetailId(detailReturnId); + message.success(t("aidpKnowledge.chunkSaveSuccess")); + + if (drawer.mode === "create" && !searchActive) { + try { + const firstPage = await aidpKnowledgeService.listChunks( + kdsId, + fileUuid, + 1, + 1 + ); + if (firstPage.total_reliable) { + setSelectedIds([]); + setPage(Math.max(1, Math.ceil(firstPage.total_count / pageSize))); + } + } catch (error) { + log.warn("Could not determine the last AIDP chunk page:", error); + } + } + }, + [ + detailReturnId, + drawer, + fileUuid, + kdsId, + message, + pageSize, + queryClient, + refreshAfterWrite, + scope.key, + searchActive, + t, + ] + ); + + const handleDelete = useCallback(async () => { + if (!deleteTargets?.length) return; + setSubmitting(true); + try { + await aidpKnowledgeService.removeChunks(kdsId, deleteTargets); + message.success(t("aidpKnowledge.chunkDeleteSuccess")); + setSelectedIds((current) => + current.filter((id) => !deleteTargets.includes(id)) + ); + if (detailId !== null && deleteTargets.includes(detailId)) { + setDetailId(null); + } + } catch (error) { + message.error( + getAidpErrorMessage(error, t, t("aidpKnowledge.chunkDeleteFailed")) + ); + } finally { + await Promise.all([ + refreshAfterWrite(true), + ...deleteTargets.map((id) => + queryClient.invalidateQueries({ + queryKey: [...scope.key, "chunk-detail", kdsId, id], + refetchType: "none", + }) + ), + ]); + setDeleteTargets(null); + setSubmitting(false); + } + }, [ + deleteTargets, + detailId, + kdsId, + message, + queryClient, + refreshAfterWrite, + scope.key, + t, + ]); + + const openCreate = useCallback(() => { + setDetailReturnId(null); + setDrawer({ mode: "create", chunk: null }); + }, []); + const openEdit = useCallback((chunk: AidpChunk) => { + // Only TEXT chunks carry editable text; TABLE/IMAGE stay read-only and + // never expose an edit entry in the UI. + if (!isAidpChunkEditable(chunk)) return; + setDetailReturnId(null); + setDrawer({ mode: "edit", chunk }); + }, []); + const openEditFromDetail = useCallback((chunk: AidpChunk) => { + if (!isAidpChunkEditable(chunk)) return; + setDetailReturnId(chunk.chunk_id); + setDetailId(null); + setDrawer({ mode: "edit", chunk }); + }, []); + const closeDrawer = useCallback(() => { + setDrawer(null); + if (detailReturnId !== null) { + setDetailId(detailReturnId); + setDetailReturnId(null); + } + }, [detailReturnId]); + const closeDetail = useCallback(() => { + setDetailId(null); + setDetailReturnId(null); + }, []); + + const loadingFirstPage = activeQuery.isPending; + const refreshing = activeQuery.isFetching && !loadingFirstPage; + const moreMenuItems = [ + { + key: "refresh", + label: t("aidpKnowledge.refresh"), + icon: , + }, + ]; + const handleMoreMenuClick = ({ key }: { key: string }) => { + if (key === "refresh") { + void refetch(); + } + }; + const handleSearchChange = (value: string) => { + setSearch(value); + setSelectedIds([]); + }; + + return ( +
+
+
+ {canEdit && ( + toggleAll(event.target.checked)} + > + {t("aidpKnowledge.chunksSelectAll")} + + )} +
+ } + placeholder={t("aidpKnowledge.chunkSearchPlaceholder")} + value={search} + onChange={(event) => handleSearchChange(event.target.value)} + aria-label={t("aidpKnowledge.chunkSearchPlaceholder")} + /> +
+ {canEdit && ( + + )} + {canEdit && ( + + )} + + + +
+
+ + {activeErrorMessage && ( + void refetch()}> + {t("aidpKnowledge.chunksRetry")} + + } + /> + )} + +
+ {loadingFirstPage ? ( +
+ +
+ ) : displayChunks.length === 0 ? ( +
+ {!activeErrorMessage && ( + + )} +
+ ) : ( + displayChunks.map((chunk, index) => ( + + toggleChunk(chunk.chunk_id, selected) + } + onOpen={() => setDetailId(chunk.chunk_id)} + onEdit={() => openEdit(chunk)} + onDelete={() => setDeleteTargets([chunk.chunk_id])} + /> + )) + )} +
+ + {!searchActive && !loadingFirstPage && chunks.length > 0 && ( +
+ +
+ )} + + + + + + setDeleteTargets(null)} + onConfirm={() => void handleDelete()} + /> +
+ ); +}; + +export default AidpSemanticChunksPanel; diff --git a/frontend/ext_components/aidp/pages/knowledge-files/hooks/useAidpFileChunks.ts b/frontend/ext_components/aidp/pages/knowledge-files/hooks/useAidpFileChunks.ts new file mode 100644 index 0000000000..b6297977d9 --- /dev/null +++ b/frontend/ext_components/aidp/pages/knowledge-files/hooks/useAidpFileChunks.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { useQuery } from "@tanstack/react-query"; +import aidpKnowledgeService from "@/ext_components/aidp/shared/services/aidpKnowledgeService"; +import { useAidpQueryScope } from "@/ext_components/aidp/shared/hooks/useAidpQueryScope"; +import type { AidpChunk } from "@/ext_components/aidp/shared/types/chunks"; +import log from "@/lib/logger"; + +/** + * Page through the chunks of one file. + * + * The query key carries the file UUID, so a late response for file A can only + * update A's cache entry and never overwrites the file B page. No previous + * data is kept, which is what clears the old list and selection on a switch. + */ +export const useAidpFileChunks = ( + id: string, + fileUuid: string, + page: number, + pageSize: number +) => { + const scope = useAidpQueryScope(); + return useQuery({ + queryKey: [...scope.key, "chunks", id, fileUuid, { page, pageSize }], + queryFn: async ({ signal }) => { + try { + return await aidpKnowledgeService.listChunks( + id, + fileUuid, + page, + pageSize, + signal + ); + } catch (error) { + if (!signal.aborted) + log.error("Failed to load AIDP file chunks:", { + id, + fileUuid, + page, + error, + }); + throw error; + } + }, + enabled: scope.enabled && !!id && !!fileUuid, + refetchOnMount: "always", + refetchOnWindowFocus: false, + retry: false, + }); +}; + +/** Search chunk content through the backend FusionSearch proxy. */ +export const useAidpChunkSearch = ( + id: string, + fileUuid: string, + fileName: string, + query: string, + topk: number = 20 +) => { + const scope = useAidpQueryScope(); + const normalizedQuery = query.trim(); + return useQuery({ + queryKey: [ + ...scope.key, + "chunk-search", + id, + fileUuid, + fileName, + normalizedQuery, + topk, + ], + queryFn: async ({ signal }) => { + try { + return await aidpKnowledgeService.searchChunks( + id, + fileUuid, + fileName, + normalizedQuery, + topk, + signal + ); + } catch (error) { + if (!signal.aborted) + log.error("Failed to search AIDP chunks:", { + id, + fileUuid, + fileName, + query: normalizedQuery, + error, + }); + throw error; + } + }, + enabled: + scope.enabled && !!id && !!fileUuid && !!fileName && !!normalizedQuery, + staleTime: 30_000, + refetchOnWindowFocus: false, + retry: false, + }); +}; + +/** Read one chunk; only issued while a detail view is open. */ +export const useAidpChunkDetail = (id: string, chunkId: number | null) => { + const scope = useAidpQueryScope(); + return useQuery({ + queryKey: [...scope.key, "chunk-detail", id, chunkId], + queryFn: async ({ signal }) => { + try { + return await aidpKnowledgeService.getChunk( + id, + chunkId as number, + signal + ); + } catch (error) { + if (!signal.aborted) + log.error("Failed to load AIDP chunk detail:", { + id, + chunkId, + error, + }); + throw error; + } + }, + enabled: scope.enabled && !!id && chunkId !== null, + refetchOnWindowFocus: false, + retry: false, + }); +}; diff --git a/frontend/ext_components/aidp/shared/services/aidpKnowledgeService.ts b/frontend/ext_components/aidp/shared/services/aidpKnowledgeService.ts index 15de5d161e..3097e14d9c 100644 --- a/frontend/ext_components/aidp/shared/services/aidpKnowledgeService.ts +++ b/frontend/ext_components/aidp/shared/services/aidpKnowledgeService.ts @@ -33,7 +33,13 @@ import type { AidpSaveSettingsResult, AidpUpdateKbPayload, } from "../types/management"; - +import type { + AidpChunk, + AidpChunkCreatePayload, + AidpChunkCreateResponse, + AidpChunkListResponse, + AidpChunkUpdatePayload, +} from "../types/chunks"; function buildUrl( base: string, params: Record @@ -417,6 +423,148 @@ class AidpKnowledgeService { return response.json(); } + /** + * List the semantic chunks AIDP generated for one file. + * + * The upstream contract filters by `file_uuid` only (no keyword search) and + * reports a global `total_count`; when the backend could not obtain one it + * marks the total unreliable so the page falls back to `has_more`. + */ + async listChunks( + id: string, + fileUuid: string, + page: number = 1, + pageSize: number = 20, + signal?: AbortSignal + ): Promise { + const url = buildUrl(API_ENDPOINTS.aidpMgmt.kbChunks(id), { + file_uuid: fileUuid, + page, + page_size: pageSize, + }); + const response = await fetchWithErrorHandling(url, { + method: "GET", + headers: getAuthHeaders(), + signal, + }); + const result = await response.json(); + return { + value: Array.isArray(result.value) ? result.value : [], + total_count: + typeof result.total_count === "number" ? result.total_count : 0, + has_more: result.has_more === true, + total_reliable: result.total_reliable !== false, + }; + } + + /** + * Search chunk content in one file through the Nexent backend's dedicated + * FusionSearch proxy. The backend applies the AIDP file-name condition; the + * page does not call the model-facing aidp_search tool. + */ + async searchChunks( + id: string, + fileUuid: string, + fileName: string, + query: string, + topk: number = 20, + signal?: AbortSignal + ): Promise { + const url = buildUrl(API_ENDPOINTS.aidpMgmt.kbChunkSearch(id), { + file_uuid: fileUuid, + file_name: fileName, + query, + topk, + }); + const response = await fetchWithErrorHandling(url, { + method: "GET", + headers: getAuthHeaders(), + signal, + }); + const result = await response.json(); + return { + value: Array.isArray(result.value) ? result.value : [], + total_count: + typeof result.total_count === "number" ? result.total_count : 0, + has_more: false, + total_reliable: true, + }; + } + + /** Read one chunk's full text and read-only metadata. */ + async getChunk( + id: string, + chunkId: number | string, + signal?: AbortSignal + ): Promise { + const response = await fetchWithErrorHandling( + API_ENDPOINTS.aidpMgmt.kbChunkDetail(id, chunkId), + { method: "GET", headers: getAuthHeaders(), signal } + ); + return (await response.json()) as AidpChunk; + } + + /** Add one chunk; AIDP answers with the new chunk id. */ + async createChunk( + id: string, + payload: AidpChunkCreatePayload + ): Promise { + const response = await fetchWithErrorHandling( + API_ENDPOINTS.aidpMgmt.kbChunks(id), + { + method: "POST", + headers: { + ...getAuthHeaders(), + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + } + ); + return (await response.json()) as AidpChunkCreateResponse; + } + + /** + * Update one chunk's text; no other field is writable upstream. + * + * AIDP regenerates the chunk vector and answers with the full updated chunk, + * which the page uses to refresh the detail's text/page_ids/metadata. + */ + async updateChunk( + id: string, + chunkId: number | string, + payload: AidpChunkUpdatePayload + ): Promise { + const response = await fetchWithErrorHandling( + API_ENDPOINTS.aidpMgmt.kbChunkDetail(id, chunkId), + { + method: "PATCH", + headers: { + ...getAuthHeaders(), + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + } + ); + return (await response.json()) as AidpChunk; + } + + /** + * Remove the selected chunks in one AIDP request (1-2000 ids). + * + * The upstream contract is a DELETE with a JSON body and a 204 empty reply; + * the response is deliberately not parsed. + */ + async removeChunks(id: string, chunkIds: number[]): Promise { + await fetchWithErrorHandling(API_ENDPOINTS.aidpMgmt.kbChunks(id), { + method: "DELETE", + headers: { + ...getAuthHeaders(), + "Content-Type": "application/json", + }, + body: JSON.stringify({ chunk_ids: chunkIds }), + }); + } + /** * Download one document through the AIDP management backend. */ diff --git a/frontend/ext_components/aidp/shared/types/chunks.ts b/frontend/ext_components/aidp/shared/types/chunks.ts new file mode 100644 index 0000000000..52774a9d68 --- /dev/null +++ b/frontend/ext_components/aidp/shared/types/chunks.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +/** + * One AIDP semantic chunk of an ingested file. + * + * Every field except `chunk_id` is optional because AIDP may omit data for a + * given chunk; the UI shows an empty state instead of inventing content. + */ +export interface AidpChunk { + /** AIDP chunk identifier; detail, edit and delete all address the chunk by it. */ + chunk_id: number; + chunk_text?: string; + /** AIDP chunk type: text, table, image, caption or multimodal. */ + chunk_type?: string; + file_name?: string; + file_url?: string; + /** Source page numbers; the contract exposes no in-page coordinates. */ + page_ids?: number[]; + /** Read-only custom metadata configured on the AIDP side. */ + metadata?: Record; + /** Only present when AIDP returns it; otherwise the detail shows "—". */ + updated_at?: string; +} + +export interface AidpChunkListResponse { + value: AidpChunk[]; + total_count: number; + has_more: boolean; + /** False when AIDP did not report a global total and it was derived. */ + total_reliable: boolean; +} + +/** Body for adding a chunk: the contract accepts only these two fields. */ +export interface AidpChunkCreatePayload { + file_uuid: string; + chunk_text: string; +} + +/** Body for updating a chunk: text is the only writable field. */ +export interface AidpChunkUpdatePayload { + chunk_text: string; +} + +/** AIDP answers a chunk creation with the new identifier. */ +export interface AidpChunkCreateResponse { + chunk_id?: number | string; +} diff --git a/frontend/ext_components/aidp/shared/utils/aidpChunkType.ts b/frontend/ext_components/aidp/shared/utils/aidpChunkType.ts new file mode 100644 index 0000000000..74c4f38a4e --- /dev/null +++ b/frontend/ext_components/aidp/shared/utils/aidpChunkType.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +/** + * AIDP reports chunk content types as TEXT, TABLE and IMAGE. Only TEXT chunks + * carry an editable text field; TABLE/IMAGE chunks are readable but not + * editable. The decision is always based on the chunk type AIDP returns, never + * on the source file's extension or the upload size class. + */ +export const AIDP_EDITABLE_CHUNK_TYPE = "TEXT"; + +export const normalizeAidpChunkType = (value?: string | null): string => + typeof value === "string" ? value.trim().toUpperCase() : ""; + +export const isAidpChunkEditable = ( + chunk?: { chunk_type?: string } | null +): boolean => + normalizeAidpChunkType(chunk?.chunk_type) === AIDP_EDITABLE_CHUNK_TYPE; diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index f30c8ba49b..af6ace98e5 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -4515,6 +4515,56 @@ "space.new": "New Space", "toolConfig.validation.array.invalid": "Please enter valid array JSON", "toolConfig.validation.object.invalid": "Please enter valid object JSON", + "aidpKnowledge.chunkUnknown": "Unknown", + "aidpKnowledge.fileDetailTitle": "Open file", + "aidpKnowledge.chunksTab": "Semantic chunks", + "aidpKnowledge.chunksSelectAll": "Select all", + "aidpKnowledge.chunksEmpty": "This file has no chunks", + "aidpKnowledge.chunksSearchEmpty": "No matching chunks", + "aidpKnowledge.chunksSearchFailed": "Failed to search chunks", + "aidpKnowledge.chunkSearchPlaceholder": "Search chunks", + "aidpKnowledge.chunkMore": "More", + "aidpKnowledge.chunksLoadFailed": "Failed to load chunks", + "aidpKnowledge.chunksRetry": "Retry", + "aidpKnowledge.chunkAdd": "Add chunk", + "aidpKnowledge.chunkDelete": "Delete", + "aidpKnowledge.chunkEdit": "Edit", + "aidpKnowledge.chunkView": "View chunk", + "aidpKnowledge.chunkSelectOne": "Select chunk #{{index}}", + "aidpKnowledge.chunkSequence": "Chunk {{index}}", + "aidpKnowledge.chunkId": "ID", + "aidpKnowledge.chunkChars": "Characters", + "aidpKnowledge.chunkUpdatedAt": "Updated", + "aidpKnowledge.chunkPages": "Pages", + "aidpKnowledge.chunkType": "Type", + "aidpKnowledge.chunkMetadata": "Metadata", + "aidpKnowledge.chunkMetadataCount": "Metadata ({{count}})", + "aidpKnowledge.chunkExpand": "Expand", + "aidpKnowledge.chunkCollapse": "Collapse", + "aidpKnowledge.chunkNoMetadata": "No metadata", + "aidpKnowledge.chunkCreateHint": "A new chunk is usually appended at the end; saving an edit re-vectorizes the chunk.", + "aidpKnowledge.chunkEditVectorHint": "Saving an edit re-vectorizes this chunk.", + "aidpKnowledge.chunkExportJson": "Export JSON", + "aidpKnowledge.chunkText": "Chunk", + "aidpKnowledge.chunkTextPlaceholder": "Enter chunk text", + "aidpKnowledge.chunkTextEmpty": "(No text)", + "aidpKnowledge.chunkTextRequired": "Chunk text is required", + "aidpKnowledge.chunkTextTooLong": "Chunk text must not exceed {{max}} characters", + "aidpKnowledge.chunkCreateTitle": "Add chunk", + "aidpKnowledge.chunkEditTitle": "Edit chunk", + "aidpKnowledge.chunkSave": "Confirm", + "aidpKnowledge.chunkSaveSuccess": "Chunk saved", + "aidpKnowledge.chunkSaveFailed": "Failed to save the chunk", + "aidpKnowledge.chunkDeleteTitle": "Delete chunks", + "aidpKnowledge.chunkDeleteBody": "You selected {{count}} chunk(s) for deletion.", + "aidpKnowledge.chunkDeleteImpact": "These chunks will no longer be retrievable after deletion. Delete them?", + "aidpKnowledge.chunkDeleteSuccess": "Chunks deleted", + "aidpKnowledge.chunkDeleteFailed": "Failed to delete chunks", + "aidpKnowledge.chunkDetailTitle": "View chunk", + "aidpKnowledge.chunkDetailLoadFailed": "Failed to load the chunk", + "aidpKnowledge.chunkPrevious": "Previous", + "aidpKnowledge.chunkNext": "Next", + "aidpKnowledge.chunkFullscreenPreview": "Full-screen preview", "aidpKnowledge.pageTitle": "AIDP Knowledge Bases", "aidpKnowledge.serverUrl": "AIDP Server URL", "aidpKnowledge.serverUrlPlaceholder": "https://aidp.example.com", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index ffc5f01fe9..5245471ed1 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -4149,6 +4149,56 @@ "systemPrompt.finetune.modeInsertDesc": "在指定位置插入新的内容", "systemPrompt.finetune.modeSelect": "替换选中", "systemPrompt.finetune.modeSelectDesc": "替换选中的内容范围", + "aidpKnowledge.chunkUnknown": "未知", + "aidpKnowledge.fileDetailTitle": "查看文件", + "aidpKnowledge.chunksTab": "语义切片", + "aidpKnowledge.chunksSelectAll": "全选", + "aidpKnowledge.chunksEmpty": "该文件暂无切片", + "aidpKnowledge.chunksSearchEmpty": "没有匹配的切片", + "aidpKnowledge.chunksSearchFailed": "搜索切片失败", + "aidpKnowledge.chunkSearchPlaceholder": "搜索切片", + "aidpKnowledge.chunkMore": "更多", + "aidpKnowledge.chunksLoadFailed": "读取切片失败", + "aidpKnowledge.chunksRetry": "重试", + "aidpKnowledge.chunkAdd": "新增切片", + "aidpKnowledge.chunkDelete": "删除", + "aidpKnowledge.chunkEdit": "编辑", + "aidpKnowledge.chunkView": "查看切片", + "aidpKnowledge.chunkSelectOne": "选择切片 #{{index}}", + "aidpKnowledge.chunkSequence": "切片 {{index}}", + "aidpKnowledge.chunkId": "ID", + "aidpKnowledge.chunkChars": "字符数", + "aidpKnowledge.chunkUpdatedAt": "更新时间", + "aidpKnowledge.chunkPages": "页码", + "aidpKnowledge.chunkType": "类型", + "aidpKnowledge.chunkMetadata": "元数据", + "aidpKnowledge.chunkMetadataCount": "元数据 ({{count}})", + "aidpKnowledge.chunkExpand": "展开", + "aidpKnowledge.chunkCollapse": "收起", + "aidpKnowledge.chunkNoMetadata": "无元数据", + "aidpKnowledge.chunkCreateHint": "新增切片一般处于最后面;修改保存后的切片将会重新向量化。", + "aidpKnowledge.chunkEditVectorHint": "修改保存后的切片将会重新向量化。", + "aidpKnowledge.chunkExportJson": "导出 JSON", + "aidpKnowledge.chunkText": "切片", + "aidpKnowledge.chunkTextPlaceholder": "请输入切片文本", + "aidpKnowledge.chunkTextEmpty": "(无文本)", + "aidpKnowledge.chunkTextRequired": "切片文本不能为空", + "aidpKnowledge.chunkTextTooLong": "切片文本不能超过 {{max}} 个字符", + "aidpKnowledge.chunkCreateTitle": "新增切片", + "aidpKnowledge.chunkEditTitle": "编辑切片", + "aidpKnowledge.chunkSave": "确定", + "aidpKnowledge.chunkSaveSuccess": "切片已保存", + "aidpKnowledge.chunkSaveFailed": "保存切片失败", + "aidpKnowledge.chunkDeleteTitle": "删除切片", + "aidpKnowledge.chunkDeleteBody": "您选择了 {{count}} 个切片删除。", + "aidpKnowledge.chunkDeleteImpact": "删除后将无法检索到这些切片,确定删除吗?", + "aidpKnowledge.chunkDeleteSuccess": "切片已删除", + "aidpKnowledge.chunkDeleteFailed": "删除切片失败", + "aidpKnowledge.chunkDetailTitle": "查看切片", + "aidpKnowledge.chunkDetailLoadFailed": "读取切片详情失败", + "aidpKnowledge.chunkPrevious": "上一个", + "aidpKnowledge.chunkNext": "下一个", + "aidpKnowledge.chunkFullscreenPreview": "全屏预览", "aidpKnowledge.pageTitle": "AIDP 知识库", "aidpKnowledge.serverUrl": "AIDP 服务器地址", "aidpKnowledge.serverUrlPlaceholder": "https://aidp.example.com", diff --git a/frontend/services/api.ts b/frontend/services/api.ts index 56a98feeab..499eb76a8f 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -481,6 +481,14 @@ export const API_ENDPOINTS = { /** PATCH endpoint for the per-KB in-group permission. */ kbPermission: (id: string) => `${API_BASE_URL}/aidp-mgmt/aidp-permissions/${id}`, + /** Semantic chunks of one ingested file: list, add, read, edit, remove. */ + kbChunks: (id: string) => + `${API_BASE_URL}/aidp-mgmt/knowledge-bases/${id}/chunks`, + /** FusionSearch-backed chunk content search scoped to one file. */ + kbChunkSearch: (id: string) => + `${API_BASE_URL}/aidp-mgmt/knowledge-bases/${id}/chunks/search`, + kbChunkDetail: (id: string, chunkId: number | string) => + `${API_BASE_URL}/aidp-mgmt/knowledge-bases/${id}/chunks/${encodeURIComponent(String(chunkId))}`, }, config: { frontend: `${API_BASE_URL}/frontend-config`, diff --git a/test/backend/ext_components/aidp/test_aidp_service_fusion.py b/test/backend/ext_components/aidp/test_aidp_service_fusion.py index eb8ddda290..c65d8f049e 100644 --- a/test/backend/ext_components/aidp/test_aidp_service_fusion.py +++ b/test/backend/ext_components/aidp/test_aidp_service_fusion.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Unit tests for ext_components.aidp.services.aidp_service.fusion_search_impl. The evaluation case-generation flow calls this helper server-side; these @@ -129,3 +131,136 @@ def test_request_error_translates_to_connection_error(self, fusion, monkeypatch) with pytest.raises(Exception) as ei: _call_fusion(fusion) assert ei.value.error_code == fusion.ErrorCode.AIDP_CONNECTION_ERROR + + +class TestChunkSearchImpl: + """The dedicated chunk search path reuses the SDK payload builder.""" + + def test_builds_file_name_condition_and_maps_hits(self, fusion, monkeypatch): + captured = {} + + def fake_request(server_url, api_key, method, path, operation, *, params=None, json_body=None): + captured.update({ + "server_url": server_url, + "api_key": api_key, + "method": method, + "path": path, + "operation": operation, + "payload": json_body, + }) + return { + "result": [ + { + "id": 1001, + "score": 0.91, + "title": "演示.pdf", + "text": "检索内容", + "chunk_type": "table", + "pages": ["2"], + "metadata": {"章节": "检索指标"}, + "file_url": "https://example.test/demo.pdf", + }, + { + "id": 2002, + "title": "其他.pdf", + "text": "不应出现", + }, + ] + } + + monkeypatch.setattr(fusion, "_request_aidp_json", fake_request) + + result = fusion.search_aidp_chunks_impl( + SERVER_URL, API_KEY, "aidp-kb-demo", "演示.pdf", "检索", top_k=5, + tenant_id="aidp", + ) + + payload = captured["payload"] + assert captured["method"] == "POST" + assert "/KnowledgeBase/Tenants/aidp/Retrieval/FusionSearch" in captured["path"] + assert payload["kds_list"] == ["aidp-kb-demo"] + assert payload["search_method"] == "vector_search|full_text_search" + assert payload["topk"] == 5 + assert payload["multi_modal_enable"] is True + assert payload["metadata_condition"] == { + "logical_operator": "or", + "conditions": [{ + "name": "original_file_name", + "comparison_operator": "=", + "value": "演示.pdf", + }], + } + assert result["total_count"] == 1 + assert result["value"][0]["chunk_id"] == 1001 + assert result["value"][0]["chunk_type"] == "TABLE" + assert result["value"][0]["page_ids"] == [2] + assert result["value"][0]["metadata"] == {"章节": "检索指标"} + + def test_non_list_result_raises_response_error(self, fusion, monkeypatch): + monkeypatch.setattr( + fusion, "_request_aidp_json", + lambda *args, **kwargs: {"result": {"unexpected": True}}, + ) + with pytest.raises(fusion.AppException) as exc_info: + fusion.search_aidp_chunks_impl( + SERVER_URL, API_KEY, "aidp-kb-demo", "演示.pdf", "检索", + ) + assert exc_info.value.error_code == fusion.ErrorCode.AIDP_RESPONSE_ERROR + + @pytest.mark.parametrize( + ("failure", "expected_code"), + [ + (httpx.ReadTimeout("upstream timed out"), "AIDP_CONNECTION_ERROR"), + ( + httpx.Response( + 200, + content=b"upstream error", + request=httpx.Request("POST", "http://aidp.example/search"), + ), + "AIDP_RESPONSE_ERROR", + ), + ], + ids=["timeout", "invalid-json"], + ) + def test_upstream_failure_keeps_error_and_does_not_retry( + self, fusion, monkeypatch, failure, expected_code + ): + client = _install_client(monkeypatch, fusion, failure) + if isinstance(failure, httpx.RequestError): + client.post.side_effect = failure + + with pytest.raises(fusion.AppException) as exc_info: + fusion.search_aidp_chunks_impl( + SERVER_URL, API_KEY, "kb-1", "report.pdf", "contract", + ) + + assert exc_info.value.error_code == getattr(fusion.ErrorCode, expected_code) + client.post.assert_called_once() + + def test_keeps_valid_hit_when_other_hits_have_no_usable_id(self, fusion, monkeypatch): + client = _install_client(monkeypatch, fusion, _ok_response({ + "result": [ + None, + {"id": "unusable", "title": "report.pdf"}, + {"id": -1, "title": "report.pdf"}, + {"id": "12", "title": "report.pdf", "text": "contract"}, + ], + })) + + result = fusion.search_aidp_chunks_impl( + SERVER_URL, API_KEY, "kb-1", "report.pdf", "contract", + ) + + assert [(row["chunk_id"], row["page_ids"]) for row in result["value"]] == [(12, [])] + client.post.assert_called_once() + + def test_rejects_whitespace_only_query_before_aidp_request(self, fusion, monkeypatch): + client = _install_client(monkeypatch, fusion, _ok_response({"result": []})) + + with pytest.raises(fusion.AppException) as exc_info: + fusion.search_aidp_chunks_impl( + SERVER_URL, API_KEY, "kb-1", "report.pdf", " ", + ) + + assert exc_info.value.error_code == fusion.ErrorCode.COMMON_VALIDATION_ERROR + client.post.assert_not_called() diff --git a/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py b/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py index a4a5c95fe5..f7e659dd6a 100644 --- a/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py +++ b/test/ext_components/aidp/mock_servers/aidp_mgmt_mock_server.py @@ -20,6 +20,11 @@ - POST /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/KnowledgeFiles/Retry (retry failed docs) - POST /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/KnowledgeFiles/Download (download doc) - GET /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/OcrAnnotate/{file_uuid} (preview doc) + - GET /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/Chunks?file_uuid= (page a file's chunks) + - GET /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/Chunks/{chunk_id} (read one chunk) + - PUT /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/Chunks (add a chunk) + - PATCH /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/Chunks/{chunk_id} (update chunk text) + - DELETE /KnowledgeBase/Tenants/{tenant}/KnowledgeBases/{id}/Chunks (remove chunks, JSON body, 204) - POST /KnowledgeBase/Tenants/{tenant}/Retrieval/FusionSearch (search - preserved from reference) Document status simulation (drives the "processing status" UI): @@ -68,7 +73,7 @@ from fastapi import FastAPI, File, Header, HTTPException, Query, UploadFile from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field, model_validator -from starlette.responses import JSONResponse, StreamingResponse +from starlette.responses import JSONResponse, Response, StreamingResponse logger = logging.getLogger("aidp_mgmt_mock") logging.basicConfig( @@ -152,6 +157,17 @@ # metadata, and a mock restart legitimately loses the bytes it received. _DOCUMENT_PAYLOADS: dict[str, bytes] = {} +# Document chunks, keyed by KB id; each chunk carries the ``file_uuid`` it +# belongs to. Real AIDP derives them while chunking an ingested file, so this +# mock generates a deterministic set per file the first time that file's chunks +# are requested, then stores and mutates exactly like the live service. +_DOCUMENT_CHUNKS_BY_KB: Dict[str, List[Dict[str, Any]]] = {} +# ``"{kds_id}:{file_uuid}"`` entries whose chunks were already generated. Kept +# separately so deleting every chunk is not undone by the next list request. +_DOCUMENT_CHUNK_GENERATED_FILES: set[str] = set() +_NEXT_DOCUMENT_CHUNK_ID: int = 1 +_DOCUMENT_CHUNK_TYPES = ("TEXT", "TEXT", "TABLE", "TEXT", "IMAGE") + # ============================================================================= # Failure injection counters (used to test retry logic end-to-end) # ============================================================================= @@ -310,6 +326,9 @@ def _save_state() -> None: payload = { "knowledge_bases": _KNOWLEDGE_BASES, "documents_by_kb": _DOCUMENTS_BY_KB, + "chunks_by_kb": _DOCUMENT_CHUNKS_BY_KB, + "chunk_generated_files": sorted(_DOCUMENT_CHUNK_GENERATED_FILES), + "next_chunk_id": _NEXT_DOCUMENT_CHUNK_ID, } tmp_path = _STATE_FILE.with_suffix(".json.tmp") try: @@ -328,6 +347,7 @@ def _load_state() -> None: is unreadable or not valid JSON, logs a warning and falls back to seeds — this keeps the mock usable after accidental file corruption. """ + global _NEXT_DOCUMENT_CHUNK_ID if _STATE_FILE.exists(): try: with open(_STATE_FILE, "r", encoding="utf-8") as f: @@ -338,6 +358,17 @@ def _load_state() -> None: raise ValueError("state file payload is not {dict, dict}") _KNOWLEDGE_BASES.update(kb_data) _DOCUMENTS_BY_KB.update(doc_data) + chunk_data = payload.get("chunks_by_kb", {}) + if isinstance(chunk_data, dict): + _DOCUMENT_CHUNKS_BY_KB.update(chunk_data) + generated = payload.get("chunk_generated_files", []) + if isinstance(generated, list): + _DOCUMENT_CHUNK_GENERATED_FILES.update( + item for item in generated if isinstance(item, str) + ) + next_id = payload.get("next_chunk_id") + if isinstance(next_id, int) and not isinstance(next_id, bool): + _NEXT_DOCUMENT_CHUNK_ID = max(_NEXT_DOCUMENT_CHUNK_ID, next_id) for kb in _KNOWLEDGE_BASES.values(): if isinstance(kb, dict): kb.pop("sensitive_intercept_enalbe", None) @@ -1124,6 +1155,25 @@ def _check_auth(authorization: Optional[str]) -> None: from starlette.responses import Response as StarletteResponse +class CreateChunkBody(BaseModel): + """Body of PUT .../Chunks (add one chunk to a file).""" + + file_uuid: uuid.UUID = Field(...) + chunk_text: str = Field(..., min_length=1, max_length=10240) + + +class UpdateChunkBody(BaseModel): + """Body of PATCH .../Chunks/{chunk_id} (text is the only writable field).""" + + chunk_text: str = Field(..., min_length=1, max_length=10240) + + +class RemoveChunksBody(BaseModel): + """Body of DELETE .../Chunks.""" + + chunk_ids: List[int] = Field(..., min_length=1, max_length=2000) + + class FailNextMiddleware(BaseHTTPMiddleware): """Intercepts real AIDP calls (under /KnowledgeBase/) and returns a configured error status for the next N requests, then lets them through. @@ -1846,6 +1896,200 @@ async def content_stream(): ) +# ============================================================================= +# Document chunks (semantic slicing) +# ============================================================================= +def _public_chunk(chunk: Dict[str, Any]) -> Dict[str, Any]: + """Return chunk fields without any mock-only private keys.""" + return {key: value for key, value in chunk.items() if not key.startswith("_")} + + +def _file_chunks(kds_id: str, file_uuid: str) -> List[Dict[str, Any]]: + return [ + chunk + for chunk in _DOCUMENT_CHUNKS_BY_KB.get(kds_id, []) + if chunk.get("file_uuid") == file_uuid + ] + + +def _next_chunk_id() -> int: + global _NEXT_DOCUMENT_CHUNK_ID + chunk_id = _NEXT_DOCUMENT_CHUNK_ID + _NEXT_DOCUMENT_CHUNK_ID += 1 + return chunk_id + + +def _ensure_file_chunks(kds_id: str, document: Dict[str, Any]) -> List[Dict[str, Any]]: + """Generate a deterministic chunk set for a file the first time it is read. + + Real AIDP chunks each ingested file; the mock has no parser, so it writes a + fixed set once and then treats it like user data (edits and removals stick, + including deleting every chunk). + """ + file_uuid = str(document.get("file_uuid")) + marker = f"{kds_id}:{file_uuid}" + if marker in _DOCUMENT_CHUNK_GENERATED_FILES: + return _file_chunks(kds_id, file_uuid) + + file_name = str(document.get("file_name") or "document") + stubs = [ + ("这是文件的标题与概述部分,用于验证语义切片的查看与编辑。", [1]), + ("第二段正文示例:AIDP 在入库时按语义边界切分文本,每个切片带有页码与元数据。", [1]), + ("表格示例:指标 | 取值 | 说明", [2]), + ("第三段正文示例:切片的文本可在此页面修改,修改只提交 chunk_text 字段。", [2, 3]), + ("图片说明示例:该切片对应文档中的插图说明文字。", [3]), + ] + chunks = _DOCUMENT_CHUNKS_BY_KB.setdefault(kds_id, []) + for index, (text, pages) in enumerate(stubs): + chunks.append({ + "chunk_id": _next_chunk_id(), + "chunk_text": text, + "chunk_type": _DOCUMENT_CHUNK_TYPES[index % len(_DOCUMENT_CHUNK_TYPES)], + "file_uuid": file_uuid, + "file_name": file_name, + "file_url": f"/aidp/knowledge/{kds_id}/{file_name}", + "page_ids": pages, + "metadata": {"section": f"part-{index + 1}", "source": file_name}, + }) + _DOCUMENT_CHUNK_GENERATED_FILES.add(marker) + _save_state() + return _file_chunks(kds_id, file_uuid) + + +def _require_file(kds_id: str, file_uuid: str) -> Dict[str, Any]: + if kds_id not in _KNOWLEDGE_BASES: + raise HTTPException(status_code=404, detail=f"Knowledge base {kds_id} not found") + document = _find_document(kds_id, file_uuid) + if document is None: + raise HTTPException(status_code=404, detail=f"File {file_uuid} not found") + return document + + +def _require_chunk(kds_id: str, chunk_id: int) -> Dict[str, Any]: + chunk = next( + ( + item + for item in _DOCUMENT_CHUNKS_BY_KB.get(kds_id, []) + if item.get("chunk_id") == chunk_id + ), + None, + ) + if chunk is None: + raise HTTPException(status_code=404, detail=f"Chunk {chunk_id} not found") + return chunk + + +@app.get(f"{_KB_PREFIX}/{{kds_id}}/Chunks") +def list_chunks( + kds_id: str, + file_uuid: uuid.UUID = Query(...), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + authorization: Optional[str] = Header(default=None), +) -> JSONResponse: + """Page through the chunks of one file, as the live contract declares.""" + _check_auth(authorization) + document = _require_file(kds_id, str(file_uuid)) + chunks = _ensure_file_chunks(kds_id, document) + start = (page - 1) * page_size + items = [_public_chunk(chunk) for chunk in chunks[start:start + page_size]] + # The current contract sends next_link=null on the last page. + has_next = page * page_size < len(chunks) + next_link = None + if has_next: + next_link = ( + f"{_KB_PREFIX}/{kds_id}/Chunks?file_uuid={file_uuid}" + f"&page={page + 1}&page_size={page_size}" + ) + logger.info( + "LIST CHUNKS kds_id=%s file=%s page=%d returned=%d total=%d", + kds_id, file_uuid, page, len(items), len(chunks), + ) + return JSONResponse(content={ + "value": items, + "next_link": next_link, + "total_count": len(chunks), + }) + + +@app.put(f"{_KB_PREFIX}/{{kds_id}}/Chunks") +def create_chunk( + kds_id: str, + body: CreateChunkBody, + authorization: Optional[str] = Header(default=None), +) -> JSONResponse: + """Add one chunk and answer with its id, as the live service does.""" + _check_auth(authorization) + document = _require_file(kds_id, str(body.file_uuid)) + if not body.chunk_text.strip(): + raise HTTPException(status_code=400, detail="chunk_text must not be blank") + chunk = { + "chunk_id": _next_chunk_id(), + "chunk_text": body.chunk_text, + "chunk_type": "TEXT", + "file_uuid": str(body.file_uuid), + "file_name": str(document.get("file_name") or ""), + "file_url": f"/aidp/knowledge/{kds_id}/{document.get('file_name') or ''}", + "page_ids": [], + "metadata": {}, + } + _DOCUMENT_CHUNKS_BY_KB.setdefault(kds_id, []).append(chunk) + _DOCUMENT_CHUNK_GENERATED_FILES.add(f"{kds_id}:{body.file_uuid}") + _save_state() + return JSONResponse(content={"chunk_id": str(chunk["chunk_id"])}) + + +@app.delete(f"{_KB_PREFIX}/{{kds_id}}/Chunks") +def remove_chunks( + kds_id: str, + body: RemoveChunksBody, + authorization: Optional[str] = Header(default=None), +) -> Response: + """Remove the requested chunks; the live contract answers 204 with no body.""" + _check_auth(authorization) + if kds_id not in _KNOWLEDGE_BASES: + raise HTTPException(status_code=404, detail=f"Knowledge base {kds_id} not found") + targets = set(body.chunk_ids) + _DOCUMENT_CHUNKS_BY_KB[kds_id] = [ + chunk + for chunk in _DOCUMENT_CHUNKS_BY_KB.get(kds_id, []) + if chunk.get("chunk_id") not in targets + ] + _save_state() + return Response(status_code=204) + + +@app.get(f"{_KB_PREFIX}/{{kds_id}}/Chunks/{{chunk_id}}") +def get_chunk( + kds_id: str, + chunk_id: int, + authorization: Optional[str] = Header(default=None), +) -> JSONResponse: + """Read one chunk.""" + _check_auth(authorization) + if kds_id not in _KNOWLEDGE_BASES: + raise HTTPException(status_code=404, detail=f"Knowledge base {kds_id} not found") + return JSONResponse(content=_public_chunk(_require_chunk(kds_id, chunk_id))) + + +@app.patch(f"{_KB_PREFIX}/{{kds_id}}/Chunks/{{chunk_id}}") +def update_chunk( + kds_id: str, + chunk_id: int, + body: UpdateChunkBody, + authorization: Optional[str] = Header(default=None), +) -> JSONResponse: + """Update one chunk's text.""" + _check_auth(authorization) + if kds_id not in _KNOWLEDGE_BASES: + raise HTTPException(status_code=404, detail=f"Knowledge base {kds_id} not found") + chunk = _require_chunk(kds_id, chunk_id) + if not body.chunk_text.strip(): + raise HTTPException(status_code=400, detail="chunk_text must not be blank") + chunk["chunk_text"] = body.chunk_text + _save_state() + return JSONResponse(content=_public_chunk(chunk)) + # ============================================================================= # Ingestion channels + knowledge-file history # ============================================================================= @@ -2048,7 +2292,7 @@ def retry_documents( "tags": ["知识库", "问答"], "standards": {"TEST_STANDARD": {"level_id": "TEST_E", "level_name": "E", "authority": "mock"}}, }, - "chunk_type": "text", + "chunk_type": "TEXT", "file_url": "", "pages": [1], "retrieval_sources": ["vector_search", "full_text_search"], @@ -2057,7 +2301,7 @@ def retry_documents( "id": 10002, "score": 0.94, "title": "AIDP产品介绍.pdf", "text": "AIDP Search keeps each retrieval chunk independent so citations can identify their own metadata snapshot.", "metadata": {"knowledge_base_id": "aidp-kb-product", "section": "citations", "count": 1, "enabled": True}, - "chunk_type": "text", "file_url": "", "pages": [2], + "chunk_type": "TEXT", "file_url": "", "pages": [2], "retrieval_sources": ["vector_search"], }, ], @@ -2068,7 +2312,7 @@ def retry_documents( "title": "常见问题汇总.txt", "text": "If no results are returned, verify the selected knowledge base IDs and API key.", "metadata": {"knowledge_base_id": "aidp-kb-faq", "section": "troubleshooting"}, - "chunk_type": "text", + "chunk_type": "TEXT", "file_url": "", "pages": [1], "retrieval_sources": ["vector_search", "full_text_search", "graph_search"], @@ -2097,10 +2341,13 @@ def _match_metadata(chunk: Dict[str, Any], condition: Optional[MetadataCondition field = c.get("name", "") op = c.get("comparison_operator", "contains") val = c.get("value", "") - cv = str(meta.get(field, "")) + if field == "original_file_name": + cv = str(chunk.get("title") or meta.get("original_file_name") or "") + else: + cv = str(meta.get(field, "")) if op == "contains": results.append(val.lower() in cv.lower()) - elif op == "equals": + elif op in ("equals", "="): results.append(cv.lower() == val.lower()) elif op == "not_equals": results.append(cv.lower() != val.lower()) @@ -2113,6 +2360,31 @@ def _match_metadata(chunk: Dict[str, Any], condition: Optional[MetadataCondition return all(results) if condition.logical_operator == "and" else any(results) +def _retrieval_chunks_for_kb(kb_id: str) -> List[Dict[str, Any]]: + """Use seeded retrieval fixtures, or adapt uploaded document chunks. + + The management mock owns the chunks created through the Chunk CRUD + endpoints; FusionSearch needs the same data for local demos. + """ + static_chunks = _CHUNKS_BY_KB.get(kb_id) + if static_chunks: + return static_chunks + chunks = [] + for chunk in _DOCUMENT_CHUNKS_BY_KB.get(kb_id, []): + chunks.append({ + "id": chunk.get("chunk_id"), + "score": 0.8, + "title": chunk.get("file_name", ""), + "text": chunk.get("chunk_text", ""), + "metadata": chunk.get("metadata", {}), + "chunk_type": chunk.get("chunk_type", "TEXT"), + "file_url": chunk.get("file_url", ""), + "pages": chunk.get("page_ids", []), + "retrieval_sources": ["vector_search", "full_text_search"], + "_search_dynamic": True, + }) + return chunks + @app.post(f"/KnowledgeBase/Tenants/{TENANT}/Retrieval/FusionSearch") def fusion_search( request: FusionSearchRequest, @@ -2146,13 +2418,20 @@ def fusion_search( for kb_id in request.kds_list: kb = _KNOWLEDGE_BASES.get(kb_id, {}) graph_available = bool(kb.get("is_exist_graph")) - for chunk in _CHUNKS_BY_KB.get(kb_id, []): + source_chunks = _retrieval_chunks_for_kb(kb_id) + for chunk in source_chunks: if not _match_metadata(chunk, request.metadata_condition): continue if not request.multi_modal_enable and chunk.get("chunk_type") == "image": continue haystack = f"{chunk.get('title', '')} {chunk.get('text', '')}".lower() + if ( + chunk.get("_search_dynamic") + and query_terms + and not any(term in haystack for term in query_terms) + ): + continue boost = min(0.05 * sum(1 for t in query_terms if t in haystack), 0.15) base_score = round(float(chunk.get("score", 0.5)) + boost, 4) reachable = [source for source in chunk.get("retrieval_sources", ["vector_search"]) @@ -2242,7 +2521,6 @@ def fusion_search( request.query, request.kds_list, request.search_method, len(payload)) return JSONResponse(content={"result": payload, "total_return_count": len(payload)}) - # ============================================================================= # ModelService — lists VLM/LLM models applicable to AIDP applications # ============================================================================= @@ -2368,6 +2646,8 @@ def reset_state() -> Dict[str, str]: _KNOWLEDGE_BASES.clear() _DOCUMENTS_BY_KB.clear() _DOCUMENT_PAYLOADS.clear() + _DOCUMENT_CHUNKS_BY_KB.clear() + _DOCUMENT_CHUNK_GENERATED_FILES.clear() _seed_initial_data() _save_state() logger.info("RESET state restored to seeds, persisted to %s", _STATE_FILE) diff --git a/test/ext_components/aidp/test_aidp_mgmt_app.py b/test/ext_components/aidp/test_aidp_mgmt_app.py index c35b152b9b..70d7ffc620 100644 --- a/test/ext_components/aidp/test_aidp_mgmt_app.py +++ b/test/ext_components/aidp/test_aidp_mgmt_app.py @@ -47,6 +47,7 @@ def _mod(name): nexent_pkg = _mod("nexent") +nexent_pkg.__path__ = [os.path.join(PROJECT_ROOT, "sdk", "nexent")] nexent_utils = _mod("nexent.utils") nexent_http_mgr = _mod("nexent.utils.http_client_manager") nexent_http_mgr.http_client_manager = MagicMock() @@ -2975,6 +2976,421 @@ def test_delete_reports_failure_and_skips_local_cleanup(self): mock_soft.assert_not_called() +class TestChunkEndpoints: + """Semantic chunk proxy: READ for reads, EDIT for writes, contract fields only.""" + + FILE_UUID = "fdde6c86-39b2-4ed5-9490-45a22d3931bc" + + def test_list_requires_read_and_forwards_file_scoped_paging(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + page = { + "value": [{"chunk_id": 1, "chunk_text": "正文"}], + "total_count": 40, + # The live service returns next_link on every page, so it must not + # keep "next" enabled on the last page. + "next_link": "/next", + } + with patch.object(aidp_permission_service, "require_permission", + return_value=MagicMock(permission="READ_ONLY")) as mock_perm, \ + patch.object(aidp_mgmt_app, "list_aidp_chunks_impl", + return_value=page) as list_chunks: + response = client.get( + f"/aidp-mgmt/knowledge-bases/kb-1/chunks?file_uuid={self.FILE_UUID}" + "&page=2&page_size=20", + headers=_bearer(), + ) + + assert response.status_code == HTTPStatus.OK + mock_perm.assert_called_once() + assert mock_perm.call_args.kwargs["required"] == "READ" + list_chunks.assert_called_once_with( + SERVER_URL, API_KEY, "kb-1", self.FILE_UUID, 2, 20 + ) + body = response.json() + assert body["total_count"] == 40 + assert body["has_more"] is False + assert body["total_reliable"] is True + + def test_search_requires_read_and_forwards_file_name(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + result = { + "value": [{"chunk_id": 1001, "chunk_text": "检索内容"}], + "total_count": 1, + "has_more": False, + "total_reliable": True, + } + with patch.object(aidp_permission_service, "require_permission", + return_value=MagicMock(permission="READ_ONLY")) as mock_perm, \ + patch.object(aidp_mgmt_app, "search_aidp_chunks_impl", + return_value=result) as search_chunks: + response = client.get( + f"/aidp-mgmt/knowledge-bases/kb-1/chunks/search" + f"?file_uuid={self.FILE_UUID}&file_name=%E6%BC%94%E7%A4%BA.pdf" + "&query=%E6%A3%80%E7%B4%A2&topk=5", + headers=_bearer(), + ) + + assert response.status_code == HTTPStatus.OK + mock_perm.assert_called_once() + assert mock_perm.call_args.kwargs["required"] == "READ" + search_chunks.assert_called_once_with( + SERVER_URL, API_KEY, "kb-1", "演示.pdf", "检索", 5 + ) + assert response.json() == result + + def test_list_reports_more_from_total_when_page_is_full(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + rows = [{"chunk_id": index} for index in range(20)] + with patch.object(aidp_permission_service, "require_permission", + return_value=MagicMock(permission="READ_ONLY")), \ + patch.object(aidp_mgmt_app, "list_aidp_chunks_impl", + return_value={"value": rows, "total_count": 21}): + response = client.get( + f"/aidp-mgmt/knowledge-bases/kb-1/chunks?file_uuid={self.FILE_UUID}" + "&page=1&page_size=20", + headers=_bearer(), + ) + + assert response.status_code == HTTPStatus.OK + assert response.json()["has_more"] is True + + def test_list_derives_count_when_upstream_total_is_missing(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + with patch.object(aidp_permission_service, "require_permission", + return_value=MagicMock(permission="READ_ONLY")), \ + patch.object(aidp_mgmt_app, "list_aidp_chunks_impl", + return_value={"value": [{"chunk_id": 1}, {"chunk_id": 2}]}): + response = client.get( + f"/aidp-mgmt/knowledge-bases/kb-1/chunks?file_uuid={self.FILE_UUID}" + "&page=3&page_size=2", + headers=_bearer(), + ) + + body = response.json() + assert body["total_reliable"] is False + assert body["total_count"] == 6 # (page - 1) * page_size + rows + assert body["has_more"] is True + + def test_list_rejects_malformed_upstream_payload(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + with patch.object(aidp_permission_service, "require_permission", + return_value=MagicMock(permission="READ_ONLY")), \ + patch.object(aidp_mgmt_app, "list_aidp_chunks_impl", + return_value={"total_count": 1}): + response = client.get( + f"/aidp-mgmt/knowledge-bases/kb-1/chunks?file_uuid={self.FILE_UUID}", + headers=_bearer(), + ) + + assert response.status_code == 502 + assert response.json()["code"] == ErrorCode.AIDP_RESPONSE_ERROR.value + + def test_read_denied_does_not_call_aidp(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + with patch.object(aidp_permission_service, "require_permission", + side_effect=aidp_mgmt_app.HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="denied")), \ + patch.object(aidp_mgmt_app, "list_aidp_chunks_impl") as list_chunks: + response = client.get( + f"/aidp-mgmt/knowledge-bases/kb-1/chunks?file_uuid={self.FILE_UUID}", + headers=_bearer(), + ) + + assert response.status_code == HTTPStatus.FORBIDDEN + list_chunks.assert_not_called() + + def test_detail_requires_read_and_forwards_chunk_id(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + chunk = {"chunk_id": 7, "chunk_text": "完整文本", "page_ids": [1]} + with patch.object(aidp_permission_service, "require_permission", + return_value=MagicMock(permission="READ_ONLY")) as mock_perm, \ + patch.object(aidp_mgmt_app, "get_aidp_chunk_impl", + return_value=chunk) as get_chunk: + response = client.get( + "/aidp-mgmt/knowledge-bases/kb-1/chunks/7", + headers=_bearer(), + ) + + assert response.status_code == HTTPStatus.OK + assert response.json() == chunk + assert mock_perm.call_args.kwargs["required"] == "READ" + get_chunk.assert_called_once_with(SERVER_URL, API_KEY, "kb-1", 7) + + def test_create_requires_edit_and_sends_trimmed_contract_fields(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + with patch.object(aidp_permission_service, "require_permission", + return_value=MagicMock(permission="EDIT")) as mock_perm, \ + patch.object(aidp_mgmt_app, "create_aidp_chunk_impl", + return_value={"chunk_id": "12"}) as create_chunk: + response = client.post( + "/aidp-mgmt/knowledge-bases/kb-1/chunks", + headers=_bearer(), + json={"file_uuid": self.FILE_UUID, "chunk_text": " 新切片 "}, + ) + + assert response.status_code == HTTPStatus.OK + assert response.json() == {"chunk_id": "12"} + assert mock_perm.call_args.kwargs["required"] == "EDIT" + create_chunk.assert_called_once_with( + SERVER_URL, API_KEY, "kb-1", self.FILE_UUID, "新切片" + ) + + @pytest.mark.parametrize("chunk_text", ["", " ", "\n\t ", "x" * 10241]) + def test_create_rejects_invalid_text_without_calling_aidp(self, chunk_text): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + with patch.object(aidp_permission_service, "require_permission", + return_value=MagicMock(permission="EDIT")), \ + patch.object(aidp_mgmt_app, "create_aidp_chunk_impl") as create_chunk: + response = client.post( + "/aidp-mgmt/knowledge-bases/kb-1/chunks", + headers=_bearer(), + json={"file_uuid": self.FILE_UUID, "chunk_text": chunk_text}, + ) + + assert response.status_code == 422 + create_chunk.assert_not_called() + + def test_create_rejects_extra_fields_without_calling_aidp(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + with patch.object(aidp_permission_service, "require_permission", + return_value=MagicMock(permission="EDIT")), \ + patch.object(aidp_mgmt_app, "create_aidp_chunk_impl") as create_chunk: + response = client.post( + "/aidp-mgmt/knowledge-bases/kb-1/chunks", + headers=_bearer(), + json={ + "file_uuid": self.FILE_UUID, + "chunk_text": "文本", + "metadata": {"k": "v"}, + }, + ) + + assert response.status_code == 422 + create_chunk.assert_not_called() + + def test_update_sends_only_chunk_text(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + updated = { + "chunk_id": 5, + "chunk_text": "改后文本", + "chunk_type": "TEXT", + "page_ids": [1], + } + with patch.object(aidp_permission_service, "require_permission", + return_value=MagicMock(permission="EDIT")) as mock_perm, \ + patch.object(aidp_mgmt_app, "get_aidp_chunk_impl", + return_value={"chunk_id": 5, "chunk_type": "TEXT"}) as get_chunk, \ + patch.object(aidp_mgmt_app, "update_aidp_chunk_impl", + return_value=updated) as update_chunk: + response = client.patch( + "/aidp-mgmt/knowledge-bases/kb-1/chunks/5", + headers=_bearer(), + json={"chunk_text": " 改后文本 "}, + ) + + assert response.status_code == HTTPStatus.OK + assert response.json() == updated + assert mock_perm.call_args.kwargs["required"] == "EDIT" + get_chunk.assert_called_once_with(SERVER_URL, API_KEY, "kb-1", 5) + update_chunk.assert_called_once_with( + SERVER_URL, API_KEY, "kb-1", 5, "改后文本" + ) + + @pytest.mark.parametrize("chunk_type", ["TABLE", "IMAGE", "table", "", None]) + def test_update_rejects_non_text_chunk_without_patching(self, chunk_type): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + current = {"chunk_id": 5} + if chunk_type is not None: + current["chunk_type"] = chunk_type + with patch.object(aidp_permission_service, "require_permission", + return_value=MagicMock(permission="EDIT")), \ + patch.object(aidp_mgmt_app, "get_aidp_chunk_impl", + return_value=current), \ + patch.object(aidp_mgmt_app, "update_aidp_chunk_impl") as update_chunk: + response = client.patch( + "/aidp-mgmt/knowledge-bases/kb-1/chunks/5", + headers=_bearer(), + json={"chunk_text": "文本"}, + ) + + assert response.status_code == 400 + assert response.json()["message"] == "Only TEXT chunks can be edited" + update_chunk.assert_not_called() + + @pytest.mark.parametrize("chunk_type", ["TEXT", "text", " Text "]) + def test_update_accepts_text_chunk_type_case_insensitively(self, chunk_type): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + with patch.object(aidp_permission_service, "require_permission", + return_value=MagicMock(permission="EDIT")), \ + patch.object(aidp_mgmt_app, "get_aidp_chunk_impl", + return_value={"chunk_id": 5, "chunk_type": chunk_type}), \ + patch.object(aidp_mgmt_app, "update_aidp_chunk_impl", + return_value={"chunk_id": 5, "chunk_type": "TEXT"}) as update_chunk: + response = client.patch( + "/aidp-mgmt/knowledge-bases/kb-1/chunks/5", + headers=_bearer(), + json={"chunk_text": "文本"}, + ) + + assert response.status_code == HTTPStatus.OK + update_chunk.assert_called_once() + + @pytest.mark.parametrize("chunk_text", ["", " ", "x" * 10241]) + def test_update_rejects_invalid_text_without_calling_aidp(self, chunk_text): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + with patch.object(aidp_permission_service, "require_permission", + return_value=MagicMock(permission="EDIT")), \ + patch.object(aidp_mgmt_app, "update_aidp_chunk_impl") as update_chunk: + response = client.patch( + "/aidp-mgmt/knowledge-bases/kb-1/chunks/5", + headers=_bearer(), + json={"chunk_text": chunk_text}, + ) + + assert response.status_code == 422 + update_chunk.assert_not_called() + + def test_write_denied_does_not_call_aidp(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + with patch.object(aidp_permission_service, "require_permission", + side_effect=aidp_mgmt_app.HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="denied")), \ + patch.object(aidp_mgmt_app, "create_aidp_chunk_impl") as create_chunk: + response = client.post( + "/aidp-mgmt/knowledge-bases/kb-1/chunks", + headers=_bearer(), + json={"file_uuid": self.FILE_UUID, "chunk_text": "文本"}, + ) + + assert response.status_code == HTTPStatus.FORBIDDEN + create_chunk.assert_not_called() + + def test_remove_forwards_selected_ids(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + with patch.object(aidp_permission_service, "require_permission", + return_value=MagicMock(permission="EDIT")) as mock_perm, \ + patch.object(aidp_mgmt_app, "remove_aidp_chunks_impl", + return_value={}) as remove_chunks: + response = client.request( + "DELETE", + "/aidp-mgmt/knowledge-bases/kb-1/chunks", + headers=_bearer(), + json={"chunk_ids": [3, 1, 2]}, + ) + + assert response.status_code == HTTPStatus.NO_CONTENT + assert response.content == b"" + assert mock_perm.call_args.kwargs["required"] == "EDIT" + remove_chunks.assert_called_once_with(SERVER_URL, API_KEY, "kb-1", [3, 1, 2]) + + @pytest.mark.parametrize("chunk_ids", [[], list(range(2001)), [-1], [True], ["1"]]) + def test_remove_rejects_id_counts_outside_1_to_2000(self, chunk_ids): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + with patch.object(aidp_permission_service, "require_permission", + return_value=MagicMock(permission="EDIT")), \ + patch.object(aidp_mgmt_app, "remove_aidp_chunks_impl") as remove_chunks: + response = client.request( + "DELETE", + "/aidp-mgmt/knowledge-bases/kb-1/chunks", + headers=_bearer(), + json={"chunk_ids": chunk_ids}, + ) + + assert response.status_code == 422 + remove_chunks.assert_not_called() + + def test_remove_accepts_the_2000_id_limit(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + chunk_ids = list(range(2000)) + with patch.object(aidp_permission_service, "require_permission", + return_value=MagicMock(permission="EDIT")), \ + patch.object(aidp_mgmt_app, "remove_aidp_chunks_impl", + return_value={}) as remove_chunks: + response = client.request( + "DELETE", + "/aidp-mgmt/knowledge-bases/kb-1/chunks", + headers=_bearer(), + json={"chunk_ids": chunk_ids}, + ) + + assert response.status_code == HTTPStatus.NO_CONTENT + remove_chunks.assert_called_once_with(SERVER_URL, API_KEY, "kb-1", chunk_ids) + + def test_upstream_failure_is_not_reported_as_success(self): + client = _client() + from ext_components.aidp.apps import aidp_mgmt_app + from ext_components.aidp.services import aidp_permission_service + + with patch.object(aidp_permission_service, "require_permission", + return_value=MagicMock(permission="EDIT")), \ + patch.object(aidp_mgmt_app, "remove_aidp_chunks_impl", + side_effect=AppException( + ErrorCode.AIDP_SERVICE_ERROR, "upstream down")): + response = client.request( + "DELETE", + "/aidp-mgmt/knowledge-bases/kb-1/chunks", + headers=_bearer(), + json={"chunk_ids": [1]}, + ) + + assert response.status_code == 502 + assert response.json()["code"] == ErrorCode.AIDP_SERVICE_ERROR.value + + _install_module_stubs = capture_module_stubs(_modules_before_stubs) diff --git a/test/ext_components/aidp/test_aidp_mgmt_mock_server.py b/test/ext_components/aidp/test_aidp_mgmt_mock_server.py index 31fd7dd8f7..4a02bec1d2 100644 --- a/test/ext_components/aidp/test_aidp_mgmt_mock_server.py +++ b/test/ext_components/aidp/test_aidp_mgmt_mock_server.py @@ -484,3 +484,122 @@ def test_chinese_demo_failure_reasons_are_json_without_top_level_code( assert "error_code" not in doc parsed = json.loads(doc["extraction_failure_reason"]) assert parsed["error_code"] and parsed["msg"] + +_CHUNK_KB = "kb-chunks" +_CHUNK_FILE = "3f8dbd1f-d5f9-43f7-89b9-b70809531647" + + +def _chunk_client(server): + server._KNOWLEDGE_BASES[_CHUNK_KB] = {"kds_name": "切片知识库"} + server._DOCUMENTS_BY_KB[_CHUNK_KB] = [ + { + "file_uuid": _CHUNK_FILE, + "file_name": "切片样例.pdf", + "status": 1, + "first_upload_time": time.time(), + } + ] + return TestClient(server.app) + + +def test_chunk_list_uses_query_and_null_next_link_on_last_page(mock_aidp_server): + client = _chunk_client(mock_aidp_server) + auth = {"Authorization": "Bearer mock-aidp-key"} + path = f"/KnowledgeBase/Tenants/aidp/KnowledgeBases/{_CHUNK_KB}/Chunks" + + first = client.get( + path, + params={"file_uuid": _CHUNK_FILE, "page": 1, "page_size": 2}, + headers=auth, + ) + assert first.status_code == 200 + body = first.json() + assert body["total_count"] == 5 + assert body["next_link"] is not None + assert [item["chunk_type"] for item in body["value"]] == ["TEXT", "TEXT"] + assert all(isinstance(item["chunk_id"], int) for item in body["value"]) + assert body["value"][0]["page_ids"] == [1] + + last = client.get( + path, + params={"file_uuid": _CHUNK_FILE, "page": 3, "page_size": 2}, + headers=auth, + ) + assert last.status_code == 200 + assert last.json()["next_link"] is None + assert [item["chunk_type"] for item in last.json()["value"]] == ["IMAGE"] + + +def test_chunk_create_returns_string_id_and_appends_to_the_end(mock_aidp_server): + client = _chunk_client(mock_aidp_server) + auth = {"Authorization": "Bearer mock-aidp-key"} + path = f"/KnowledgeBase/Tenants/aidp/KnowledgeBases/{_CHUNK_KB}/Chunks" + + # Generate the file's five seed chunks first so the new id is deterministic. + client.get(path, params={"file_uuid": _CHUNK_FILE}, headers=auth) + created = client.put( + path, + headers=auth, + json={"file_uuid": _CHUNK_FILE, "chunk_text": "新增切片"}, + ) + assert created.status_code == 200 + assert created.json() == {"chunk_id": "6"} + + page = client.get( + path, + params={"file_uuid": _CHUNK_FILE, "page": 1, "page_size": 100}, + headers=auth, + ).json() + assert page["total_count"] == 6 + assert page["value"][-1]["chunk_id"] == 6 + assert page["value"][-1]["chunk_text"] == "新增切片" + assert page["value"][-1]["chunk_type"] == "TEXT" + + +def test_chunk_update_returns_the_full_chunk_object(mock_aidp_server): + client = _chunk_client(mock_aidp_server) + auth = {"Authorization": "Bearer mock-aidp-key"} + path = f"/KnowledgeBase/Tenants/aidp/KnowledgeBases/{_CHUNK_KB}/Chunks" + + # Generate the seed chunks, then edit chunk 1 (TEXT). + client.get(path, params={"file_uuid": _CHUNK_FILE}, headers=auth) + updated = client.patch(f"{path}/1", headers=auth, json={"chunk_text": "改后文本"}) + assert updated.status_code == 200 + body = updated.json() + assert body["chunk_id"] == 1 + assert body["chunk_text"] == "改后文本" + assert body["chunk_type"] == "TEXT" + assert body["page_ids"] == [1] + assert body["metadata"]["section"] == "part-1" + + +def test_chunk_delete_requires_json_body_and_answers_204(mock_aidp_server): + client = _chunk_client(mock_aidp_server) + auth = {"Authorization": "Bearer mock-aidp-key"} + path = f"/KnowledgeBase/Tenants/aidp/KnowledgeBases/{_CHUNK_KB}/Chunks" + client.get(path, params={"file_uuid": _CHUNK_FILE}, headers=auth) + + removed = client.request( + "DELETE", + path, + headers=auth, + json={"chunk_ids": [1, 2]}, + ) + assert removed.status_code == 204 + assert removed.content == b"" + + page = client.get( + path, + params={"file_uuid": _CHUNK_FILE, "page": 1, "page_size": 100}, + headers=auth, + ).json() + assert page["total_count"] == 3 + assert [item["chunk_id"] for item in page["value"]] == [3, 4, 5] + + # The old upstream path is gone from this contract. + legacy = client.post( + f"{path}/Remove", + headers=auth, + json={"chunk_ids": [3]}, + ) + assert legacy.status_code in (404, 405) diff --git a/test/ext_components/aidp/test_aidp_service.py b/test/ext_components/aidp/test_aidp_service.py index 2503e03726..89fb07191c 100644 --- a/test/ext_components/aidp/test_aidp_service.py +++ b/test/ext_components/aidp/test_aidp_service.py @@ -4060,3 +4060,195 @@ async def test_http_error_without_response_still_maps_the_status( ) assert exc_info.value.error_code == ErrorCode.AIDP_SERVICE_ERROR + + +class TestChunkImpls: + """The chunk adapter forwards exactly the fields the AIDP contract declares.""" + + @staticmethod + def _install_client(aidp_service_module, client): + mock_manager = MagicMock() + mock_manager.get_sync_client.return_value = client + aidp_service_module.http_client_manager = mock_manager + return mock_manager + + @staticmethod + def _json_response(payload, status_code=200, content=None): + response = MagicMock(status_code=status_code) + response.raise_for_status.return_value = None + response.json.return_value = payload + response.content = content if content is not None else b"{}" + return response + + def test_list_forwards_file_scoped_query_and_normalizes_ids( + self, aidp_service_module + ): + response = self._json_response({ + "value": [ + { + "chunk_id": "7", + "chunk_text": "正文", + "chunk_type": "text", + "page_ids": ["1", "2", 3], + "metadata": {"section": "a"}, + }, + {"chunk_id": 8, "metadata": "unexpected", "page_ids": "1"}, + ], + "total_count": 42, + "next_link": "/next", + }) + mock_client = MagicMock() + mock_client.get.return_value = response + self._install_client(aidp_service_module, mock_client) + + result = aidp_service_module.list_aidp_chunks_impl( + "http://127.0.0.1:30081", "jwt-token", "kb-1", + "fdde6c86-39b2-4ed5-9490-45a22d3931bc", 2, 20, + ) + + mock_client.get.assert_called_once() + url = mock_client.get.call_args.args[0] + assert url == ( + "http://127.0.0.1:30081/KnowledgeBase/Tenants/aidp" + "/KnowledgeBases/kb-1/Chunks" + ) + assert mock_client.get.call_args.kwargs["params"] == { + "file_uuid": "fdde6c86-39b2-4ed5-9490-45a22d3931bc", + "page": 2, + "page_size": 20, + } + assert result["total_count"] == 42 + assert result["value"][0]["chunk_id"] == 7 + assert result["value"][0]["page_ids"] == [1, 2, 3] + assert result["value"][1]["chunk_id"] == 8 + assert result["value"][1]["page_ids"] == [] + assert result["value"][1]["metadata"] == {} + + def test_list_rejects_payload_without_chunk_list(self, aidp_service_module): + mock_client = MagicMock() + mock_client.get.return_value = self._json_response( + {"unexpected": True}, content=b'{"unexpected": true}' + ) + self._install_client(aidp_service_module, mock_client) + + with pytest.raises(AppException) as exc_info: + aidp_service_module.list_aidp_chunks_impl( + "http://127.0.0.1:30081", "jwt-token", "kb-1", "uuid-1" + ) + + assert exc_info.value.error_code == ErrorCode.AIDP_RESPONSE_ERROR + + def test_detail_reads_chunk_scoped_path(self, aidp_service_module): + mock_client = MagicMock() + mock_client.get.return_value = self._json_response({ + "chunk_id": "9", + "chunk_text": "完整文本", + "file_name": "西游记.pdf", + }) + self._install_client(aidp_service_module, mock_client) + + result = aidp_service_module.get_aidp_chunk_impl( + "http://127.0.0.1:30081", "jwt-token", "kb-1", 9 + ) + + assert mock_client.get.call_args.args[0] == ( + "http://127.0.0.1:30081/KnowledgeBase/Tenants/aidp" + "/KnowledgeBases/kb-1/Chunks/9" + ) + assert result["chunk_id"] == 9 + assert result["chunk_text"] == "完整文本" + + def test_create_sends_only_file_uuid_and_text(self, aidp_service_module): + mock_client = MagicMock() + mock_client.put.return_value = self._json_response({"chunk_id": "12"}) + self._install_client(aidp_service_module, mock_client) + + result = aidp_service_module.create_aidp_chunk_impl( + "http://127.0.0.1:30081", "jwt-token", "kb-1", "uuid-1", "新文本" + ) + + assert mock_client.put.call_args.args[0] == ( + "http://127.0.0.1:30081/KnowledgeBase/Tenants/aidp" + "/KnowledgeBases/kb-1/Chunks" + ) + assert mock_client.put.call_args.kwargs["json"] == { + "file_uuid": "uuid-1", + "chunk_text": "新文本", + } + assert result == {"chunk_id": 12} + + def test_update_sends_only_chunk_text_and_returns_full_chunk( + self, aidp_service_module + ): + mock_client = MagicMock() + mock_client.patch.return_value = self._json_response({ + "chunk_id": "5", + "chunk_text": "改后文本", + "chunk_type": "TEXT", + "page_ids": ["1", "2"], + "metadata": {"section": "a"}, + }) + self._install_client(aidp_service_module, mock_client) + + result = aidp_service_module.update_aidp_chunk_impl( + "http://127.0.0.1:30081", "jwt-token", "kb-1", 5, "改后文本" + ) + + assert mock_client.patch.call_args.args[0] == ( + "http://127.0.0.1:30081/KnowledgeBase/Tenants/aidp" + "/KnowledgeBases/kb-1/Chunks/5" + ) + assert mock_client.patch.call_args.kwargs["json"] == {"chunk_text": "改后文本"} + assert result["chunk_id"] == 5 + assert result["chunk_text"] == "改后文本" + assert result["page_ids"] == [1, 2] + + def test_update_tolerates_empty_success_body(self, aidp_service_module): + mock_client = MagicMock() + mock_client.patch.return_value = self._json_response({}, content=b"") + self._install_client(aidp_service_module, mock_client) + + result = aidp_service_module.update_aidp_chunk_impl( + "http://127.0.0.1:30081", "jwt-token", "kb-1", 5, "改后文本" + ) + + assert result == {} + + def test_remove_uses_delete_with_json_body_and_204(self, aidp_service_module): + mock_client = MagicMock() + mock_client.request.return_value = self._json_response( + {}, status_code=204, content=b"" + ) + self._install_client(aidp_service_module, mock_client) + + result = aidp_service_module.remove_aidp_chunks_impl( + "http://127.0.0.1:30081", "jwt-token", "kb-1", [1, 2, 3] + ) + + assert mock_client.request.call_args.args == ( + "DELETE", + "http://127.0.0.1:30081/KnowledgeBase/Tenants/aidp" + "/KnowledgeBases/kb-1/Chunks", + ) + assert mock_client.request.call_args.kwargs["json"] == {"chunk_ids": [1, 2, 3]} + assert result == {} + mock_client.post.assert_not_called() + + def test_upstream_failure_is_not_reported_as_success(self, aidp_service_module): + response = MagicMock(status_code=500) + response.json.return_value = {"message": "inkernel error"} + response.raise_for_status.side_effect = httpx.HTTPStatusError( + "boom", request=MagicMock(), response=response + ) + mock_client = MagicMock() + mock_client.patch.return_value = response + self._install_client(aidp_service_module, mock_client) + + with pytest.raises(AppException) as exc_info: + aidp_service_module.update_aidp_chunk_impl( + "http://127.0.0.1:30081", "jwt-token", "kb-1", 5, "文本" + ) + + assert exc_info.value.error_code == ErrorCode.AIDP_SERVICE_ERROR + mock_client.patch.assert_called_once() + assert exc_info.value.details["upstream_status"] == 500