This commit is contained in:
Raphaël MANSUY 2025-12-04 19:18:39 +08:00
parent 75d392f377
commit 2a451c4e22
11 changed files with 718 additions and 107 deletions

View file

@ -17,6 +17,7 @@ class EntityUpdateRequest(BaseModel):
entity_name: str entity_name: str
updated_data: Dict[str, Any] updated_data: Dict[str, Any]
allow_rename: bool = False allow_rename: bool = False
allow_merge: bool = False
class RelationUpdateRequest(BaseModel): class RelationUpdateRequest(BaseModel):
@ -221,22 +222,178 @@ def create_graph_routes(rag, api_key: Optional[str] = None):
""" """
Update an entity's properties in the knowledge graph Update an entity's properties in the knowledge graph
This endpoint allows updating entity properties, including renaming entities.
When renaming to an existing entity name, the behavior depends on allow_merge:
Args: Args:
request (EntityUpdateRequest): Request containing entity name, updated data, and rename flag request (EntityUpdateRequest): Request containing:
- entity_name (str): Name of the entity to update
- updated_data (Dict[str, Any]): Dictionary of properties to update
- allow_rename (bool): Whether to allow entity renaming (default: False)
- allow_merge (bool): Whether to merge into existing entity when renaming
causes name conflict (default: False)
Returns: Returns:
Dict: Updated entity information Dict with the following structure:
{
"status": "success",
"message": "Entity updated successfully" | "Entity merged successfully into 'target_name'",
"data": {
"entity_name": str, # Final entity name
"description": str, # Entity description
"entity_type": str, # Entity type
"source_id": str, # Source chunk IDs
... # Other entity properties
},
"operation_summary": {
"merged": bool, # Whether entity was merged into another
"merge_status": str, # "success" | "failed" | "not_attempted"
"merge_error": str | None, # Error message if merge failed
"operation_status": str, # "success" | "partial_success" | "failure"
"target_entity": str | None, # Target entity name if renaming/merging
"final_entity": str, # Final entity name after operation
"renamed": bool # Whether entity was renamed
}
}
operation_status values explained:
- "success": All operations completed successfully
* For simple updates: entity properties updated
* For renames: entity renamed successfully
* For merges: non-name updates applied AND merge completed
- "partial_success": Update succeeded but merge failed
* Non-name property updates were applied successfully
* Merge operation failed (entity not merged)
* Original entity still exists with updated properties
* Use merge_error for failure details
- "failure": Operation failed completely
* If merge_status == "failed": Merge attempted but both update and merge failed
* If merge_status == "not_attempted": Regular update failed
* No changes were applied to the entity
merge_status values explained:
- "success": Entity successfully merged into target entity
- "failed": Merge operation was attempted but failed
- "not_attempted": No merge was attempted (normal update/rename)
Behavior when renaming to an existing entity:
- If allow_merge=False: Raises ValueError with 400 status (default behavior)
- If allow_merge=True: Automatically merges the source entity into the existing target entity,
preserving all relationships and applying non-name updates first
Example Request (simple update):
POST /graph/entity/edit
{
"entity_name": "Tesla",
"updated_data": {"description": "Updated description"},
"allow_rename": false,
"allow_merge": false
}
Example Response (simple update success):
{
"status": "success",
"message": "Entity updated successfully",
"data": { ... },
"operation_summary": {
"merged": false,
"merge_status": "not_attempted",
"merge_error": null,
"operation_status": "success",
"target_entity": null,
"final_entity": "Tesla",
"renamed": false
}
}
Example Request (rename with auto-merge):
POST /graph/entity/edit
{
"entity_name": "Elon Msk",
"updated_data": {
"entity_name": "Elon Musk",
"description": "Corrected description"
},
"allow_rename": true,
"allow_merge": true
}
Example Response (merge success):
{
"status": "success",
"message": "Entity merged successfully into 'Elon Musk'",
"data": { ... },
"operation_summary": {
"merged": true,
"merge_status": "success",
"merge_error": null,
"operation_status": "success",
"target_entity": "Elon Musk",
"final_entity": "Elon Musk",
"renamed": true
}
}
Example Response (partial success - update succeeded but merge failed):
{
"status": "success",
"message": "Entity updated successfully",
"data": { ... }, # Data reflects updated "Elon Msk" entity
"operation_summary": {
"merged": false,
"merge_status": "failed",
"merge_error": "Target entity locked by another operation",
"operation_status": "partial_success",
"target_entity": "Elon Musk",
"final_entity": "Elon Msk", # Original entity still exists
"renamed": true
}
}
""" """
try: try:
result = await rag.aedit_entity( result = await rag.aedit_entity(
entity_name=request.entity_name, entity_name=request.entity_name,
updated_data=request.updated_data, updated_data=request.updated_data,
allow_rename=request.allow_rename, allow_rename=request.allow_rename,
allow_merge=request.allow_merge,
)
# Extract operation_summary from result, with fallback for backward compatibility
operation_summary = result.get(
"operation_summary",
{
"merged": False,
"merge_status": "not_attempted",
"merge_error": None,
"operation_status": "success",
"target_entity": None,
"final_entity": request.updated_data.get(
"entity_name", request.entity_name
),
"renamed": request.updated_data.get(
"entity_name", request.entity_name
)
!= request.entity_name,
},
)
# Separate entity data from operation_summary for clean response
entity_data = dict(result)
entity_data.pop("operation_summary", None)
# Generate appropriate response message based on merge status
response_message = (
f"Entity merged successfully into '{operation_summary['final_entity']}'"
if operation_summary.get("merged")
else "Entity updated successfully"
) )
return { return {
"status": "success", "status": "success",
"message": "Entity updated successfully", "message": response_message,
"data": result, "data": entity_data,
"operation_summary": operation_summary,
} }
except ValueError as ve: except ValueError as ve:
logger.error( logger.error(
@ -365,7 +522,7 @@ def create_graph_routes(rag, api_key: Optional[str] = None):
This endpoint establishes an undirected relationship between two existing entities. This endpoint establishes an undirected relationship between two existing entities.
The provided source/target order is accepted for convenience, but the backend The provided source/target order is accepted for convenience, but the backend
stored edge is undirected and may be returned with the entities swapped. stored edge is undirected and may be returned with the entities swapped.
Both entities must already exist in the knowledge graph. The system automatically Both entities must already exist in the knowledge graph. The system automatically
generates vector embeddings for the relationship to enable semantic search and graph traversal. generates vector embeddings for the relationship to enable semantic search and graph traversal.

View file

@ -22,6 +22,7 @@ from typing import (
Dict, Dict,
) )
from lightrag.prompt import PROMPTS from lightrag.prompt import PROMPTS
from lightrag.exceptions import PipelineCancelledException
from lightrag.constants import ( from lightrag.constants import (
DEFAULT_MAX_GLEANING, DEFAULT_MAX_GLEANING,
DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE, DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE,
@ -1603,6 +1604,7 @@ class LightRAG:
"batchs": 0, # Total number of files to be processed "batchs": 0, # Total number of files to be processed
"cur_batch": 0, # Number of files already processed "cur_batch": 0, # Number of files already processed
"request_pending": False, # Clear any previous request "request_pending": False, # Clear any previous request
"cancellation_requested": False, # Initialize cancellation flag
"latest_message": "", "latest_message": "",
} }
) )
@ -1619,6 +1621,22 @@ class LightRAG:
try: try:
# Process documents until no more documents or requests # Process documents until no more documents or requests
while True: while True:
# Check for cancellation request at the start of main loop
async with pipeline_status_lock:
if pipeline_status.get("cancellation_requested", False):
# Clear pending request
pipeline_status["request_pending"] = False
# Celar cancellation flag
pipeline_status["cancellation_requested"] = False
log_message = "Pipeline cancelled by user"
logger.info(log_message)
pipeline_status["latest_message"] = log_message
pipeline_status["history_messages"].append(log_message)
# Exit directly, skipping request_pending check
return
if not to_process_docs: if not to_process_docs:
log_message = "All enqueued documents have been processed" log_message = "All enqueued documents have been processed"
logger.info(log_message) logger.info(log_message)
@ -1681,14 +1699,25 @@ class LightRAG:
semaphore: asyncio.Semaphore, semaphore: asyncio.Semaphore,
) -> None: ) -> None:
"""Process single document""" """Process single document"""
# Initialize variables at the start to prevent UnboundLocalError in error handling
file_path = "unknown_source"
current_file_number = 0
file_extraction_stage_ok = False file_extraction_stage_ok = False
processing_start_time = int(time.time())
first_stage_tasks = []
entity_relation_task = None
async with semaphore: async with semaphore:
nonlocal processed_count nonlocal processed_count
current_file_number = 0
# Initialize to prevent UnboundLocalError in error handling # Initialize to prevent UnboundLocalError in error handling
first_stage_tasks = [] first_stage_tasks = []
entity_relation_task = None entity_relation_task = None
try: try:
# Check for cancellation before starting document processing
async with pipeline_status_lock:
if pipeline_status.get("cancellation_requested", False):
raise PipelineCancelledException("User cancelled")
# Get file path from status document # Get file path from status document
file_path = getattr( file_path = getattr(
status_doc, "file_path", "unknown_source" status_doc, "file_path", "unknown_source"
@ -1751,6 +1780,11 @@ class LightRAG:
# Record processing start time # Record processing start time
processing_start_time = int(time.time()) processing_start_time = int(time.time())
# Check for cancellation before entity extraction
async with pipeline_status_lock:
if pipeline_status.get("cancellation_requested", False):
raise PipelineCancelledException("User cancelled")
# Process document in two stages # Process document in two stages
# Stage 1: Process text chunks and docs (parallel execution) # Stage 1: Process text chunks and docs (parallel execution)
doc_status_task = asyncio.create_task( doc_status_task = asyncio.create_task(
@ -1805,16 +1839,29 @@ class LightRAG:
file_extraction_stage_ok = True file_extraction_stage_ok = True
except Exception as e: except Exception as e:
# Log error and update pipeline status # Check if this is a user cancellation
logger.error(traceback.format_exc()) if isinstance(e, PipelineCancelledException):
error_msg = f"Failed to extract document {current_file_number}/{total_files}: {file_path}" # User cancellation - log brief message only, no traceback
logger.error(error_msg) error_msg = f"User cancelled {current_file_number}/{total_files}: {file_path}"
async with pipeline_status_lock: logger.warning(error_msg)
pipeline_status["latest_message"] = error_msg async with pipeline_status_lock:
pipeline_status["history_messages"].append( pipeline_status["latest_message"] = error_msg
traceback.format_exc() pipeline_status["history_messages"].append(
) error_msg
pipeline_status["history_messages"].append(error_msg) )
else:
# Other exceptions - log with traceback
logger.error(traceback.format_exc())
error_msg = f"Failed to extract document {current_file_number}/{total_files}: {file_path}"
logger.error(error_msg)
async with pipeline_status_lock:
pipeline_status["latest_message"] = error_msg
pipeline_status["history_messages"].append(
traceback.format_exc()
)
pipeline_status["history_messages"].append(
error_msg
)
# Cancel tasks that are not yet completed # Cancel tasks that are not yet completed
all_tasks = first_stage_tasks + ( all_tasks = first_stage_tasks + (
@ -1824,9 +1871,14 @@ class LightRAG:
if task and not task.done(): if task and not task.done():
task.cancel() task.cancel()
# Persistent llm cache # Persistent llm cache with error handling
if self.llm_response_cache: if self.llm_response_cache:
await self.llm_response_cache.index_done_callback() try:
await self.llm_response_cache.index_done_callback()
except Exception as persist_error:
logger.error(
f"Failed to persist LLM cache: {persist_error}"
)
# Record processing end time for failed case # Record processing end time for failed case
processing_end_time = int(time.time()) processing_end_time = int(time.time())
@ -1856,6 +1908,15 @@ class LightRAG:
# Concurrency is controlled by keyed lock for individual entities and relationships # Concurrency is controlled by keyed lock for individual entities and relationships
if file_extraction_stage_ok: if file_extraction_stage_ok:
try: try:
# Check for cancellation before merge
async with pipeline_status_lock:
if pipeline_status.get(
"cancellation_requested", False
):
raise PipelineCancelledException(
"User cancelled"
)
# Get chunk_results from entity_relation_task # Get chunk_results from entity_relation_task
chunk_results = await entity_relation_task chunk_results = await entity_relation_task
await merge_nodes_and_edges( await merge_nodes_and_edges(
@ -1914,22 +1975,38 @@ class LightRAG:
) )
except Exception as e: except Exception as e:
# Log error and update pipeline status # Check if this is a user cancellation
logger.error(traceback.format_exc()) if isinstance(e, PipelineCancelledException):
error_msg = f"Merging stage failed in document {current_file_number}/{total_files}: {file_path}" # User cancellation - log brief message only, no traceback
logger.error(error_msg) error_msg = f"User cancelled during merge {current_file_number}/{total_files}: {file_path}"
async with pipeline_status_lock: logger.warning(error_msg)
pipeline_status["latest_message"] = error_msg async with pipeline_status_lock:
pipeline_status["history_messages"].append( pipeline_status["latest_message"] = error_msg
traceback.format_exc() pipeline_status["history_messages"].append(
) error_msg
pipeline_status["history_messages"].append( )
error_msg else:
) # Other exceptions - log with traceback
logger.error(traceback.format_exc())
error_msg = f"Merging stage failed in document {current_file_number}/{total_files}: {file_path}"
logger.error(error_msg)
async with pipeline_status_lock:
pipeline_status["latest_message"] = error_msg
pipeline_status["history_messages"].append(
traceback.format_exc()
)
pipeline_status["history_messages"].append(
error_msg
)
# Persistent llm cache # Persistent llm cache with error handling
if self.llm_response_cache: if self.llm_response_cache:
await self.llm_response_cache.index_done_callback() try:
await self.llm_response_cache.index_done_callback()
except Exception as persist_error:
logger.error(
f"Failed to persist LLM cache: {persist_error}"
)
# Record processing end time for failed case # Record processing end time for failed case
processing_end_time = int(time.time()) processing_end_time = int(time.time())
@ -1970,7 +2047,19 @@ class LightRAG:
) )
# Wait for all document processing to complete # Wait for all document processing to complete
await asyncio.gather(*doc_tasks) try:
await asyncio.gather(*doc_tasks)
except PipelineCancelledException:
# Cancel all remaining tasks
for task in doc_tasks:
if not task.done():
task.cancel()
# Wait for all tasks to complete cancellation
await asyncio.wait(doc_tasks, return_when=asyncio.ALL_COMPLETED)
# Exit directly (document statuses already updated in process_document)
return
# Check if there's a pending request to process more documents (with lock) # Check if there's a pending request to process more documents (with lock)
has_pending_request = False has_pending_request = False
@ -2001,11 +2090,14 @@ class LightRAG:
to_process_docs.update(pending_docs) to_process_docs.update(pending_docs)
finally: finally:
log_message = "Enqueued document processing pipeline stoped" log_message = "Enqueued document processing pipeline stopped"
logger.info(log_message) logger.info(log_message)
# Always reset busy status when done or if an exception occurs (with lock) # Always reset busy status and cancellation flag when done or if an exception occurs (with lock)
async with pipeline_status_lock: async with pipeline_status_lock:
pipeline_status["busy"] = False pipeline_status["busy"] = False
pipeline_status["cancellation_requested"] = (
False # Always reset cancellation flag
)
pipeline_status["latest_message"] = log_message pipeline_status["latest_message"] = log_message
pipeline_status["history_messages"].append(log_message) pipeline_status["history_messages"].append(log_message)
@ -3210,6 +3302,10 @@ class LightRAG:
list(entities_to_delete) list(entities_to_delete)
) )
# Delete from entity_chunks storage
if self.entity_chunks:
await self.entity_chunks.delete(list(entities_to_delete))
async with pipeline_status_lock: async with pipeline_status_lock:
log_message = f"Successfully deleted {len(entities_to_delete)} entities" log_message = f"Successfully deleted {len(entities_to_delete)} entities"
logger.info(log_message) logger.info(log_message)
@ -3239,6 +3335,14 @@ class LightRAG:
list(relationships_to_delete) list(relationships_to_delete)
) )
# Delete from relation_chunks storage
if self.relation_chunks:
relation_storage_keys = [
make_relation_chunk_key(src, tgt)
for src, tgt in relationships_to_delete
]
await self.relation_chunks.delete(relation_storage_keys)
async with pipeline_status_lock: async with pipeline_status_lock:
log_message = f"Successfully deleted {len(relationships_to_delete)} relations" log_message = f"Successfully deleted {len(relationships_to_delete)} relations"
logger.info(log_message) logger.info(log_message)
@ -3302,9 +3406,7 @@ class LightRAG:
pipeline_status["history_messages"].append(cache_log_message) pipeline_status["history_messages"].append(cache_log_message)
log_message = cache_log_message log_message = cache_log_message
except Exception as cache_delete_error: except Exception as cache_delete_error:
log_message = ( log_message = f"Failed to delete LLM cache for document {doc_id}: {cache_delete_error}"
f"Failed to delete LLM cache for document {doc_id}: {cache_delete_error}"
)
logger.error(log_message) logger.error(log_message)
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
async with pipeline_status_lock: async with pipeline_status_lock:
@ -3475,16 +3577,22 @@ class LightRAG:
) )
async def aedit_entity( async def aedit_entity(
self, entity_name: str, updated_data: dict[str, str], allow_rename: bool = True self,
entity_name: str,
updated_data: dict[str, str],
allow_rename: bool = True,
allow_merge: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Asynchronously edit entity information. """Asynchronously edit entity information.
Updates entity information in the knowledge graph and re-embeds the entity in the vector database. Updates entity information in the knowledge graph and re-embeds the entity in the vector database.
Also synchronizes entity_chunks_storage and relation_chunks_storage to track chunk references.
Args: Args:
entity_name: Name of the entity to edit entity_name: Name of the entity to edit
updated_data: Dictionary containing updated attributes, e.g. {"description": "new description", "entity_type": "new type"} updated_data: Dictionary containing updated attributes, e.g. {"description": "new description", "entity_type": "new type"}
allow_rename: Whether to allow entity renaming, defaults to True allow_rename: Whether to allow entity renaming, defaults to True
allow_merge: Whether to merge into an existing entity when renaming to an existing name
Returns: Returns:
Dictionary containing updated entity information Dictionary containing updated entity information
@ -3498,14 +3606,21 @@ class LightRAG:
entity_name, entity_name,
updated_data, updated_data,
allow_rename, allow_rename,
allow_merge,
self.entity_chunks,
self.relation_chunks,
) )
def edit_entity( def edit_entity(
self, entity_name: str, updated_data: dict[str, str], allow_rename: bool = True self,
entity_name: str,
updated_data: dict[str, str],
allow_rename: bool = True,
allow_merge: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
loop = always_get_an_event_loop() loop = always_get_an_event_loop()
return loop.run_until_complete( return loop.run_until_complete(
self.aedit_entity(entity_name, updated_data, allow_rename) self.aedit_entity(entity_name, updated_data, allow_rename, allow_merge)
) )
async def aedit_relation( async def aedit_relation(
@ -3514,6 +3629,7 @@ class LightRAG:
"""Asynchronously edit relation information. """Asynchronously edit relation information.
Updates relation (edge) information in the knowledge graph and re-embeds the relation in the vector database. Updates relation (edge) information in the knowledge graph and re-embeds the relation in the vector database.
Also synchronizes the relation_chunks_storage to track which chunks reference this relation.
Args: Args:
source_entity: Name of the source entity source_entity: Name of the source entity
@ -3532,6 +3648,7 @@ class LightRAG:
source_entity, source_entity,
target_entity, target_entity,
updated_data, updated_data,
self.relation_chunks,
) )
def edit_relation( def edit_relation(
@ -3643,6 +3760,8 @@ class LightRAG:
target_entity, target_entity,
merge_strategy, merge_strategy,
target_entity_data, target_entity_data,
self.entity_chunks,
self.relation_chunks,
) )
def merge_entities( def merge_entities(

View file

@ -540,7 +540,33 @@ async def aedit_entity(
relation_chunks_storage: Optional KV storage for tracking chunks that reference relations relation_chunks_storage: Optional KV storage for tracking chunks that reference relations
Returns: Returns:
Dictionary containing updated entity information Dictionary containing updated entity information and operation summary with the following structure:
{
"entity_name": str, # Name of the entity
"description": str, # Entity description
"entity_type": str, # Entity type
"source_id": str, # Source chunk IDs
... # Other entity properties
"operation_summary": {
"merged": bool, # Whether entity was merged
"merge_status": str, # "success" | "failed" | "not_attempted"
"merge_error": str | None, # Error message if merge failed
"operation_status": str, # "success" | "partial_success" | "failure"
"target_entity": str | None, # Target entity name if renaming/merging
"final_entity": str, # Final entity name after operation
"renamed": bool # Whether entity was renamed
}
}
operation_status values:
- "success": Operation completed successfully (update/rename/merge all succeeded)
- "partial_success": Non-name updates succeeded but merge failed
- "failure": Operation failed completely
merge_status values:
- "success": Entity successfully merged into target
- "failed": Merge operation failed
- "not_attempted": No merge was attempted (normal update/rename)
""" """
new_entity_name = updated_data.get("entity_name", entity_name) new_entity_name = updated_data.get("entity_name", entity_name)
is_renaming = new_entity_name != entity_name is_renaming = new_entity_name != entity_name
@ -549,6 +575,16 @@ async def aedit_entity(
workspace = entities_vdb.global_config.get("workspace", "") workspace = entities_vdb.global_config.get("workspace", "")
namespace = f"{workspace}:GraphDB" if workspace else "GraphDB" namespace = f"{workspace}:GraphDB" if workspace else "GraphDB"
operation_summary: dict[str, Any] = {
"merged": False,
"merge_status": "not_attempted",
"merge_error": None,
"operation_status": "success",
"target_entity": None,
"final_entity": new_entity_name if is_renaming else entity_name,
"renamed": is_renaming,
}
async with get_storage_keyed_lock( async with get_storage_keyed_lock(
lock_keys, namespace=namespace, enable_logging=False lock_keys, namespace=namespace, enable_logging=False
): ):
@ -572,38 +608,93 @@ async def aedit_entity(
f"Entity Edit: `{entity_name}` will be merged into `{new_entity_name}`" f"Entity Edit: `{entity_name}` will be merged into `{new_entity_name}`"
) )
# Track whether non-name updates were applied
non_name_updates_applied = False
non_name_updates = { non_name_updates = {
key: value key: value
for key, value in updated_data.items() for key, value in updated_data.items()
if key != "entity_name" if key != "entity_name"
} }
# Apply non-name updates first
if non_name_updates: if non_name_updates:
logger.info( try:
"Entity Edit: applying non-name updates before merge" logger.info(
) "Entity Edit: applying non-name updates before merge"
await _edit_entity_impl( )
await _edit_entity_impl(
chunk_entity_relation_graph,
entities_vdb,
relationships_vdb,
entity_name,
non_name_updates,
entity_chunks_storage=entity_chunks_storage,
relation_chunks_storage=relation_chunks_storage,
)
non_name_updates_applied = True
except Exception as update_error:
# If update fails, re-raise immediately
logger.error(
f"Entity Edit: non-name updates failed: {update_error}"
)
raise
# Attempt to merge entities
try:
merge_result = await _merge_entities_impl(
chunk_entity_relation_graph, chunk_entity_relation_graph,
entities_vdb, entities_vdb,
relationships_vdb, relationships_vdb,
entity_name, [entity_name],
non_name_updates, new_entity_name,
merge_strategy=None,
target_entity_data=None,
entity_chunks_storage=entity_chunks_storage, entity_chunks_storage=entity_chunks_storage,
relation_chunks_storage=relation_chunks_storage, relation_chunks_storage=relation_chunks_storage,
) )
return await _merge_entities_impl( # Merge succeeded
chunk_entity_relation_graph, operation_summary.update(
entities_vdb, {
relationships_vdb, "merged": True,
[entity_name], "merge_status": "success",
new_entity_name, "merge_error": None,
merge_strategy=None, "operation_status": "success",
target_entity_data=None, "target_entity": new_entity_name,
entity_chunks_storage=entity_chunks_storage, "final_entity": new_entity_name,
relation_chunks_storage=relation_chunks_storage, }
) )
return {**merge_result, "operation_summary": operation_summary}
return await _edit_entity_impl( except Exception as merge_error:
# Merge failed, but update may have succeeded
logger.error(f"Entity Edit: merge failed: {merge_error}")
# Return partial success status (update succeeded but merge failed)
operation_summary.update(
{
"merged": False,
"merge_status": "failed",
"merge_error": str(merge_error),
"operation_status": "partial_success"
if non_name_updates_applied
else "failure",
"target_entity": new_entity_name,
"final_entity": entity_name, # Keep source entity name
}
)
# Get current entity info (with applied updates if any)
entity_info = await get_entity_info(
chunk_entity_relation_graph,
entities_vdb,
entity_name,
include_vector_data=True,
)
return {**entity_info, "operation_summary": operation_summary}
# Normal edit flow (no merge involved)
edit_result = await _edit_entity_impl(
chunk_entity_relation_graph, chunk_entity_relation_graph,
entities_vdb, entities_vdb,
relationships_vdb, relationships_vdb,
@ -612,6 +703,9 @@ async def aedit_entity(
entity_chunks_storage=entity_chunks_storage, entity_chunks_storage=entity_chunks_storage,
relation_chunks_storage=relation_chunks_storage, relation_chunks_storage=relation_chunks_storage,
) )
operation_summary["operation_status"] = "success"
return {**edit_result, "operation_summary": operation_summary}
except Exception as e: except Exception as e:
logger.error(f"Error while editing entity '{entity_name}': {e}") logger.error(f"Error while editing entity '{entity_name}': {e}")
raise raise

View file

@ -143,6 +143,21 @@ export type QueryResponse = {
response: string response: string
} }
export type EntityUpdateResponse = {
status: string
message: string
data: Record<string, any>
operation_summary?: {
merged: boolean
merge_status: 'success' | 'failed' | 'not_attempted'
merge_error: string | null
operation_status: 'success' | 'partial_success' | 'failure'
target_entity: string | null
final_entity?: string | null
renamed?: boolean
}
}
export type DocActionResponse = { export type DocActionResponse = {
status: 'success' | 'partial_success' | 'failure' | 'duplicated' status: 'success' | 'partial_success' | 'failure' | 'duplicated'
message: string message: string
@ -716,17 +731,20 @@ export const loginToServer = async (username: string, password: string): Promise
* @param entityName The name of the entity to update * @param entityName The name of the entity to update
* @param updatedData Dictionary containing updated attributes * @param updatedData Dictionary containing updated attributes
* @param allowRename Whether to allow renaming the entity (default: false) * @param allowRename Whether to allow renaming the entity (default: false)
* @param allowMerge Whether to merge into an existing entity when renaming to a duplicate name
* @returns Promise with the updated entity information * @returns Promise with the updated entity information
*/ */
export const updateEntity = async ( export const updateEntity = async (
entityName: string, entityName: string,
updatedData: Record<string, any>, updatedData: Record<string, any>,
allowRename: boolean = false allowRename: boolean = false,
): Promise<DocActionResponse> => { allowMerge: boolean = false
): Promise<EntityUpdateResponse> => {
const response = await axiosInstance.post('/graph/entity/edit', { const response = await axiosInstance.post('/graph/entity/edit', {
entity_name: entityName, entity_name: entityName,
updated_data: updatedData, updated_data: updatedData,
allow_rename: allowRename allow_rename: allowRename,
allow_merge: allowMerge
}) })
return response.data return response.data
} }

View file

@ -3,6 +3,16 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { updateEntity, updateRelation, checkEntityNameExists } from '@/api/lightrag' import { updateEntity, updateRelation, checkEntityNameExists } from '@/api/lightrag'
import { useGraphStore } from '@/stores/graph' import { useGraphStore } from '@/stores/graph'
import { useSettingsStore } from '@/stores/settings'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/Dialog'
import Button from '@/components/ui/Button'
import { PropertyName, EditIcon, PropertyValue } from './PropertyRowComponents' import { PropertyName, EditIcon, PropertyValue } from './PropertyRowComponents'
import PropertyEditDialog from './PropertyEditDialog' import PropertyEditDialog from './PropertyEditDialog'
@ -48,6 +58,12 @@ const EditablePropertyRow = ({
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [currentValue, setCurrentValue] = useState(initialValue) const [currentValue, setCurrentValue] = useState(initialValue)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [mergeDialogOpen, setMergeDialogOpen] = useState(false)
const [mergeDialogInfo, setMergeDialogInfo] = useState<{
targetEntity: string
sourceEntity: string
} | null>(null)
useEffect(() => { useEffect(() => {
setCurrentValue(initialValue) setCurrentValue(initialValue)
@ -56,42 +72,111 @@ const EditablePropertyRow = ({
const handleEditClick = () => { const handleEditClick = () => {
if (isEditable && !isEditing) { if (isEditable && !isEditing) {
setIsEditing(true) setIsEditing(true)
setErrorMessage(null)
} }
} }
const handleCancel = () => { const handleCancel = () => {
setIsEditing(false) setIsEditing(false)
setErrorMessage(null)
} }
const handleSave = async (value: string) => { const handleSave = async (value: string, options?: { allowMerge?: boolean }) => {
if (isSubmitting || value === String(currentValue)) { if (isSubmitting || value === String(currentValue)) {
setIsEditing(false) setIsEditing(false)
setErrorMessage(null)
return return
} }
setIsSubmitting(true) setIsSubmitting(true)
setErrorMessage(null)
try { try {
if (entityType === 'node' && entityId && nodeId) { if (entityType === 'node' && entityId && nodeId) {
let updatedData = { [name]: value } let updatedData = { [name]: value }
const allowMerge = options?.allowMerge ?? false
if (name === 'entity_id') { if (name === 'entity_id') {
const exists = await checkEntityNameExists(value) if (!allowMerge) {
if (exists) { const exists = await checkEntityNameExists(value)
toast.error(t('graphPanel.propertiesView.errors.duplicateName')) if (exists) {
return const errorMsg = t('graphPanel.propertiesView.errors.duplicateName')
setErrorMessage(errorMsg)
toast.error(errorMsg)
return
}
} }
updatedData = { 'entity_name': value } updatedData = { 'entity_name': value }
} }
await updateEntity(entityId, updatedData, true) const response = await updateEntity(entityId, updatedData, true, allowMerge)
try { const operationSummary = response.operation_summary
await useGraphStore.getState().updateNodeAndSelect(nodeId, entityId, name, value) const operationStatus = operationSummary?.operation_status || 'complete_success'
} catch (error) { const finalValue = operationSummary?.final_entity ?? value
console.error('Error updating node in graph:', error)
throw new Error('Failed to update node in graph') // Handle different operation statuses
if (operationStatus === 'success') {
if (operationSummary?.merged) {
// Node was successfully merged into an existing entity
setMergeDialogInfo({
targetEntity: finalValue,
sourceEntity: entityId,
})
setMergeDialogOpen(true)
toast.success(t('graphPanel.propertiesView.success.entityMerged'))
} else {
// Node was updated/renamed normally
try {
await useGraphStore
.getState()
.updateNodeAndSelect(nodeId, entityId, name, finalValue)
} catch (error) {
console.error('Error updating node in graph:', error)
throw new Error('Failed to update node in graph')
}
toast.success(t('graphPanel.propertiesView.success.entityUpdated'))
}
// Update local state and notify parent component
// For entity_id updates, use finalValue (which may be different due to merging)
// For other properties, use the original value the user entered
const valueToSet = name === 'entity_id' ? finalValue : value
setCurrentValue(valueToSet)
onValueChange?.(valueToSet)
} else if (operationStatus === 'partial_success') {
// Partial success: update succeeded but merge failed
// Do NOT update graph data to keep frontend in sync with backend
const mergeError = operationSummary?.merge_error || 'Unknown error'
const errorMsg = t('graphPanel.propertiesView.errors.updateSuccessButMergeFailed', {
error: mergeError
})
setErrorMessage(errorMsg)
toast.error(errorMsg)
// Do not update currentValue or call onValueChange
return
} else {
// Complete failure or unknown status
// Check if this was a merge attempt or just a regular update
if (operationSummary?.merge_status === 'failed') {
// Merge operation was attempted but failed
const mergeError = operationSummary?.merge_error || 'Unknown error'
const errorMsg = t('graphPanel.propertiesView.errors.mergeFailed', {
error: mergeError
})
setErrorMessage(errorMsg)
toast.error(errorMsg)
} else {
// Regular update failed (no merge involved)
const errorMsg = t('graphPanel.propertiesView.errors.updateFailed')
setErrorMessage(errorMsg)
toast.error(errorMsg)
}
// Do not update currentValue or call onValueChange
return
} }
toast.success(t('graphPanel.propertiesView.success.entityUpdated'))
} else if (entityType === 'edge' && sourceId && targetId && edgeId && dynamicId) { } else if (entityType === 'edge' && sourceId && targetId && edgeId && dynamicId) {
const updatedData = { [name]: value } const updatedData = { [name]: value }
await updateRelation(sourceId, targetId, updatedData) await updateRelation(sourceId, targetId, updatedData)
@ -102,19 +187,42 @@ const EditablePropertyRow = ({
throw new Error('Failed to update edge in graph') throw new Error('Failed to update edge in graph')
} }
toast.success(t('graphPanel.propertiesView.success.relationUpdated')) toast.success(t('graphPanel.propertiesView.success.relationUpdated'))
setCurrentValue(value)
onValueChange?.(value)
} }
setIsEditing(false) setIsEditing(false)
setCurrentValue(value)
onValueChange?.(value)
} catch (error) { } catch (error) {
console.error('Error updating property:', error) console.error('Error updating property:', error)
toast.error(t('graphPanel.propertiesView.errors.updateFailed')) const errorMsg = error instanceof Error ? error.message : t('graphPanel.propertiesView.errors.updateFailed')
setErrorMessage(errorMsg)
toast.error(errorMsg)
return
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
} }
const handleMergeRefresh = (useMergedStart: boolean) => {
const info = mergeDialogInfo
const graphState = useGraphStore.getState()
const settingsState = useSettingsStore.getState()
graphState.clearSelection()
graphState.setGraphDataFetchAttempted(false)
graphState.setLastSuccessfulQueryLabel('')
if (useMergedStart && info?.targetEntity) {
settingsState.setQueryLabel(info.targetEntity)
} else {
graphState.incrementGraphDataVersion()
}
setMergeDialogOpen(false)
setMergeDialogInfo(null)
toast.info(t('graphPanel.propertiesView.mergeDialog.refreshing'))
}
return ( return (
<div className="flex items-center gap-1 overflow-hidden"> <div className="flex items-center gap-1 overflow-hidden">
<PropertyName name={name} /> <PropertyName name={name} />
@ -131,7 +239,45 @@ const EditablePropertyRow = ({
propertyName={name} propertyName={name}
initialValue={String(currentValue)} initialValue={String(currentValue)}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
errorMessage={errorMessage}
/> />
<Dialog
open={mergeDialogOpen}
onOpenChange={(open) => {
setMergeDialogOpen(open)
if (!open) {
setMergeDialogInfo(null)
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('graphPanel.propertiesView.mergeDialog.title')}</DialogTitle>
<DialogDescription>
{t('graphPanel.propertiesView.mergeDialog.description', {
source: mergeDialogInfo?.sourceEntity ?? '',
target: mergeDialogInfo?.targetEntity ?? '',
})}
</DialogDescription>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t('graphPanel.propertiesView.mergeDialog.refreshHint')}
</p>
<DialogFooter className="mt-4 flex-col gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => handleMergeRefresh(false)}
>
{t('graphPanel.propertiesView.mergeDialog.keepCurrentStart')}
</Button>
<Button type="button" onClick={() => handleMergeRefresh(true)}>
{t('graphPanel.propertiesView.mergeDialog.useMergedStart')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
) )
} }

View file

@ -9,14 +9,16 @@ import {
DialogDescription DialogDescription
} from '@/components/ui/Dialog' } from '@/components/ui/Dialog'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import Checkbox from '@/components/ui/Checkbox'
interface PropertyEditDialogProps { interface PropertyEditDialogProps {
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
onSave: (value: string) => void onSave: (value: string, options?: { allowMerge?: boolean }) => void
propertyName: string propertyName: string
initialValue: string initialValue: string
isSubmitting?: boolean isSubmitting?: boolean
errorMessage?: string | null
} }
/** /**
@ -29,17 +31,18 @@ const PropertyEditDialog = ({
onSave, onSave,
propertyName, propertyName,
initialValue, initialValue,
isSubmitting = false isSubmitting = false,
errorMessage = null
}: PropertyEditDialogProps) => { }: PropertyEditDialogProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const [value, setValue] = useState('') const [value, setValue] = useState('')
// Add error state to display save failure messages const [allowMerge, setAllowMerge] = useState(false)
const [error, setError] = useState<string | null>(null)
// Initialize value when dialog opens // Initialize value when dialog opens
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setValue(initialValue) setValue(initialValue)
setAllowMerge(false)
} }
}, [isOpen, initialValue]) }, [isOpen, initialValue])
@ -86,18 +89,8 @@ const PropertyEditDialog = ({
const handleSave = async () => { const handleSave = async () => {
if (value.trim() !== '') { if (value.trim() !== '') {
// Clear previous error messages const options = propertyName === 'entity_id' ? { allowMerge } : undefined
setError(null) await onSave(value, options)
try {
await onSave(value)
onClose()
} catch (error) {
console.error('Save error:', error)
// Set error message to state for UI display
setError(typeof error === 'object' && error !== null
? (error as Error).message || t('common.saveFailed')
: t('common.saveFailed'))
}
} }
} }
@ -116,9 +109,9 @@ const PropertyEditDialog = ({
</DialogHeader> </DialogHeader>
{/* Display error message if save fails */} {/* Display error message if save fails */}
{error && ( {errorMessage && (
<div className="bg-destructive/15 text-destructive px-4 py-2 rounded-md text-sm mt-2"> <div className="bg-destructive/15 text-destructive px-4 py-2 rounded-md text-sm">
{error} {errorMessage}
</div> </div>
)} )}
@ -146,6 +139,25 @@ const PropertyEditDialog = ({
})()} })()}
</div> </div>
{propertyName === 'entity_id' && (
<div className="rounded-md border border-border bg-muted/20 p-3">
<label className="flex items-start gap-2 text-sm font-medium">
<Checkbox
id="allow-merge"
checked={allowMerge}
disabled={isSubmitting}
onCheckedChange={(checked) => setAllowMerge(checked === true)}
/>
<div>
<span>{t('graphPanel.propertiesView.mergeOptionLabel')}</span>
<p className="text-xs font-normal text-muted-foreground">
{t('graphPanel.propertiesView.mergeOptionDescription')}
</p>
</div>
</label>
</div>
)}
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"

View file

@ -305,11 +305,24 @@
"errors": { "errors": {
"duplicateName": "اسم العقدة موجود بالفعل", "duplicateName": "اسم العقدة موجود بالفعل",
"updateFailed": "فشل تحديث العقدة", "updateFailed": "فشل تحديث العقدة",
"tryAgainLater": "يرجى المحاولة مرة أخرى لاحقًا" "tryAgainLater": "يرجى المحاولة مرة أخرى لاحقًا",
"updateSuccessButMergeFailed": "تم تحديث الخصائص، لكن الدمج فشل: {{error}}",
"mergeFailed": "فشل الدمج: {{error}}"
}, },
"success": { "success": {
"entityUpdated": "تم تحديث العقدة بنجاح", "entityUpdated": "تم تحديث العقدة بنجاح",
"relationUpdated": "تم تحديث العلاقة بنجاح" "relationUpdated": "تم تحديث العلاقة بنجاح",
"entityMerged": "تم دمج العقد بنجاح"
},
"mergeOptionLabel": "دمج تلقائي عند العثور على اسم مكرر",
"mergeOptionDescription": "عند التفعيل، سيتم دمج هذه العقدة تلقائيًا في العقدة الموجودة بدلاً من ظهور خطأ عند إعادة التسمية بنفس الاسم.",
"mergeDialog": {
"title": "تم دمج العقدة",
"description": "\"{{source}}\" تم دمجها في \"{{target}}\".",
"refreshHint": "يجب تحديث الرسم البياني لتحميل البنية الأحدث.",
"keepCurrentStart": "تحديث مع الحفاظ على عقدة البدء الحالية",
"useMergedStart": "تحديث واستخدام العقدة المدمجة كنقطة بدء",
"refreshing": "جارٍ تحديث الرسم البياني..."
}, },
"node": { "node": {
"title": "عقدة", "title": "عقدة",

View file

@ -305,11 +305,24 @@
"errors": { "errors": {
"duplicateName": "Node name already exists", "duplicateName": "Node name already exists",
"updateFailed": "Failed to update node", "updateFailed": "Failed to update node",
"tryAgainLater": "Please try again later" "tryAgainLater": "Please try again later",
"updateSuccessButMergeFailed": "Properties updated, but merge failed: {{error}}",
"mergeFailed": "Merge failed: {{error}}"
}, },
"success": { "success": {
"entityUpdated": "Node updated successfully", "entityUpdated": "Node updated successfully",
"relationUpdated": "Relation updated successfully" "relationUpdated": "Relation updated successfully",
"entityMerged": "Nodes merged successfully"
},
"mergeOptionLabel": "Automatically merge when a duplicate name is found",
"mergeOptionDescription": "If enabled, renaming to an existing name will merge this node into the existing one instead of failing.",
"mergeDialog": {
"title": "Node merged",
"description": "\"{{source}}\" has been merged into \"{{target}}\".",
"refreshHint": "Refresh the graph to load the latest structure.",
"keepCurrentStart": "Refresh and keep current start node",
"useMergedStart": "Refresh and use merged node",
"refreshing": "Refreshing graph..."
}, },
"node": { "node": {
"title": "Node", "title": "Node",

View file

@ -305,11 +305,24 @@
"errors": { "errors": {
"duplicateName": "Le nom du nœud existe déjà", "duplicateName": "Le nom du nœud existe déjà",
"updateFailed": "Échec de la mise à jour du nœud", "updateFailed": "Échec de la mise à jour du nœud",
"tryAgainLater": "Veuillez réessayer plus tard" "tryAgainLater": "Veuillez réessayer plus tard",
"updateSuccessButMergeFailed": "Propriétés mises à jour, mais la fusion a échoué : {{error}}",
"mergeFailed": "Échec de la fusion : {{error}}"
}, },
"success": { "success": {
"entityUpdated": "Nœud mis à jour avec succès", "entityUpdated": "Nœud mis à jour avec succès",
"relationUpdated": "Relation mise à jour avec succès" "relationUpdated": "Relation mise à jour avec succès",
"entityMerged": "Fusion des nœuds réussie"
},
"mergeOptionLabel": "Fusionner automatiquement en cas de nom dupliqué",
"mergeOptionDescription": "Si activé, renommer vers un nom existant fusionnera automatiquement ce nœud avec celui-ci au lieu d'échouer.",
"mergeDialog": {
"title": "Nœud fusionné",
"description": "\"{{source}}\" a été fusionné dans \"{{target}}\".",
"refreshHint": "Actualisez le graphe pour charger la structure la plus récente.",
"keepCurrentStart": "Actualiser en conservant le nœud de départ actuel",
"useMergedStart": "Actualiser en utilisant le nœud fusionné",
"refreshing": "Actualisation du graphe..."
}, },
"node": { "node": {
"title": "Nœud", "title": "Nœud",

View file

@ -305,11 +305,24 @@
"errors": { "errors": {
"duplicateName": "节点名称已存在", "duplicateName": "节点名称已存在",
"updateFailed": "更新节点失败", "updateFailed": "更新节点失败",
"tryAgainLater": "请稍后重试" "tryAgainLater": "请稍后重试",
"updateSuccessButMergeFailed": "属性已更新,但合并失败:{{error}}",
"mergeFailed": "合并失败:{{error}}"
}, },
"success": { "success": {
"entityUpdated": "节点更新成功", "entityUpdated": "节点更新成功",
"relationUpdated": "关系更新成功" "relationUpdated": "关系更新成功",
"entityMerged": "节点合并成功"
},
"mergeOptionLabel": "重名时自动合并",
"mergeOptionDescription": "勾选后,重命名为已存在的名称会将当前节点自动合并过去,而不会报错。",
"mergeDialog": {
"title": "节点已合并",
"description": "\"{{source}}\" 已合并到 \"{{target}}\"。",
"refreshHint": "请刷新图谱以获取最新结构。",
"keepCurrentStart": "刷新并保持当前起始节点",
"useMergedStart": "刷新并以合并后的节点为起始节点",
"refreshing": "正在刷新图谱..."
}, },
"node": { "node": {
"title": "节点", "title": "节点",

View file

@ -305,11 +305,24 @@
"errors": { "errors": {
"duplicateName": "節點名稱已存在", "duplicateName": "節點名稱已存在",
"updateFailed": "更新節點失敗", "updateFailed": "更新節點失敗",
"tryAgainLater": "請稍後重試" "tryAgainLater": "請稍後重試",
"updateSuccessButMergeFailed": "屬性已更新,但合併失敗:{{error}}",
"mergeFailed": "合併失敗:{{error}}"
}, },
"success": { "success": {
"entityUpdated": "節點更新成功", "entityUpdated": "節點更新成功",
"relationUpdated": "關係更新成功" "relationUpdated": "關係更新成功",
"entityMerged": "節點合併成功"
},
"mergeOptionLabel": "遇到重名時自動合併",
"mergeOptionDescription": "勾選後,重新命名為既有名稱時會自動將當前節點合併過去,不再報錯。",
"mergeDialog": {
"title": "節點已合併",
"description": "\"{{source}}\" 已合併到 \"{{target}}\"。",
"refreshHint": "請重新整理圖譜以取得最新結構。",
"keepCurrentStart": "重新整理並保留目前的起始節點",
"useMergedStart": "重新整理並以合併後的節點為起始節點",
"refreshing": "正在重新整理圖譜..."
}, },
"node": { "node": {
"title": "節點", "title": "節點",