Fix: update tree when delete skill node (#18063)

This commit is contained in:
Lynn
2026-08-11 09:49:22 +08:00
committed by GitHub
parent 38c5d7c338
commit 15ab2b3ee2
2 changed files with 184 additions and 9 deletions

View File

@@ -2831,12 +2831,68 @@ async def delete_skills(dataset_id: str, tenant_id: str):
return True, {"deleted": int(deleted or 0)}
async def delete_skill(dataset_id: str, tenant_id: str, skill_kwd: str):
"""Delete a single compiled skill node identified by ``skill_kwd``.
def _parse_list_field(value):
"""Normalize a search-result ``_kwd`` field to a Python list.
Removes the per-node ``skill`` row(s) matching ``skill_kwd`` (the same
identity ``get_skill_page`` reads). The aggregate ``skill_all`` tree row is
left untouched. Returns ``(True, {"deleted": <n>})``.
Infinity stores keyword fields as ``###``-joined strings;
ES / OpenSearch / OceanBase keep native lists.
"""
if isinstance(value, list):
return [v for v in value if v]
if isinstance(value, str) and value:
return [v for v in value.split("###") if v]
return []
def _walk_skill_tree(tree: list[dict], target_kwd: str, parent_kwd: str | None = None):
"""Depth-first search through the ``skill_all`` tree JSON.
Returns ``(parent_kwd, found_node)`` or ``(None, None)`` when *target_kwd*
is not present in the tree.
"""
for node in tree:
if node.get("skill_kwd") == target_kwd:
return parent_kwd, node
children = node.get("children_kwd", [])
if children:
result = _walk_skill_tree(children, target_kwd, node.get("skill_kwd"))
p, n = result
if n is not None:
return p, n
return None, None
def _collect_tree_descendants(node: dict) -> set[str]:
"""Return all ``skill_kwd`` values from *node*'s subtree (recursive)."""
result: set[str] = set()
for child in node.get("children_kwd", []):
kwd = child.get("skill_kwd", "")
if kwd:
result.add(kwd)
result.update(_collect_tree_descendants(child))
return result
def _prune_skill_tree(tree: list[dict], deleted_kwds: set[str]) -> list[dict]:
"""Remove nodes whose ``skill_kwd`` is in *deleted_kwds* (and their subtrees)."""
result = []
for node in tree:
if node.get("skill_kwd") in deleted_kwds:
continue
children = node.get("children_kwd", [])
node["children_kwd"] = _prune_skill_tree(children, deleted_kwds)
result.append(node)
return result
async def delete_skill(dataset_id: str, tenant_id: str, skill_kwd: str):
"""Delete a compiled skill node and its descendants.
1. Walk the ``skill_all`` tree to identify the target node, its parent,
and all descendant ``skill_kwd`` values.
2. Delete every matching ``compile_kwd="skill"`` row (self + subtree).
3. If a parent exists, remove the deleted node from its ``children_kwd``.
4. Prune the ``skill_all`` tree and rewrite the aggregate row.
"""
if not KnowledgebaseService.accessible(dataset_id, tenant_id):
return False, "no authorization"
@@ -2847,16 +2903,124 @@ async def delete_skill(dataset_id: str, tenant_id: str, skill_kwd: str):
return True, {"deleted": 0}
index_nm, _ = pack
# ------------------------------------------------------------------
# 1. Read current skill_all tree
# ------------------------------------------------------------------
from common.doc_store.doc_store_base import OrderByExpr
_ALL_SELECT = ["id", "kb_id", "doc_id", "compile_kwd", "skill_with_weight", "available_int"]
try:
res = settings.docStoreConn.search(
select_fields=_ALL_SELECT,
highlight_fields=[],
condition={"compile_kwd": [_SKILL_ALL_COMPILE_KWD]},
match_expressions=[],
order_by=OrderByExpr(),
offset=0,
limit=1,
index_names=index_nm,
knowledgebase_ids=[dataset_id],
)
field_map = settings.docStoreConn.get_fields(res, _ALL_SELECT)
except Exception:
logging.exception("delete_skill: read skill_all failed kb=%s skill=%s", dataset_id, skill_kwd)
return False, "Failed to read skill tree."
if not field_map:
return True, {"deleted": 0}
_, skill_all_row = next(iter(field_map.items()))
raw_tree = skill_all_row.get("skill_with_weight", "[]")
tree = json.loads(raw_tree) if isinstance(raw_tree, str) else raw_tree
tree = tree if isinstance(tree, list) else [tree]
# ------------------------------------------------------------------
# 2. Locate target node and collect descendants
# ------------------------------------------------------------------
parent_kwd, found_node = _walk_skill_tree(tree, skill_kwd)
if found_node is None:
# Fallback: node not tracked in the tree; try direct delete anyway.
try:
deleted = settings.docStoreConn.delete(
{"compile_kwd": [_SKILL_COMPILE_KWD], "skill_kwd": [skill_kwd]},
index_nm,
dataset_id,
)
except Exception:
logging.exception("delete_skill: fallback delete failed kb=%s skill=%s", dataset_id, skill_kwd)
return False, "Failed to delete skill."
return True, {"deleted": int(deleted or 0)}
descendant_kwds = _collect_tree_descendants(found_node)
all_kwds = [skill_kwd] + sorted(descendant_kwds)
# ------------------------------------------------------------------
# 3. Delete compile_kwd="skill" rows (self + all descendants)
# ------------------------------------------------------------------
try:
deleted = settings.docStoreConn.delete(
{"compile_kwd": [_SKILL_COMPILE_KWD], "skill_kwd": [skill_kwd]},
{"compile_kwd": [_SKILL_COMPILE_KWD], "skill_kwd": all_kwds},
index_nm,
dataset_id,
)
except Exception:
logging.exception("delete_skill: docStore delete failed for kb=%s skill=%s", dataset_id, skill_kwd)
logging.exception("delete_skill: delete failed kb=%s skill=%s kwds=%s", dataset_id, skill_kwd, all_kwds)
return False, "Failed to delete skill."
# ------------------------------------------------------------------
# 4. Update parent's children_kwd
# ------------------------------------------------------------------
if parent_kwd:
_NODE_SELECT = [
"id",
"kb_id",
"doc_id",
"compile_kwd",
"skill_kwd",
"depth_int",
"children_kwd",
"source_doc_ids",
"md_with_weight",
"available_int",
"parent_kwd",
]
try:
res = settings.docStoreConn.search(
select_fields=_NODE_SELECT,
highlight_fields=[],
condition={"compile_kwd": [_SKILL_COMPILE_KWD], "skill_kwd": [parent_kwd]},
match_expressions=[],
order_by=OrderByExpr(),
offset=0,
limit=1,
index_names=index_nm,
knowledgebase_ids=[dataset_id],
)
fm = settings.docStoreConn.get_fields(res, _NODE_SELECT)
except Exception:
logging.exception("delete_skill: read parent failed kb=%s parent=%s", dataset_id, parent_kwd)
else:
if fm:
_, parent_row = next(iter(fm.items()))
children = [c for c in _parse_list_field(parent_row.get("children_kwd", [])) if c != skill_kwd]
parent_row["children_kwd"] = children
try:
settings.docStoreConn.insert([parent_row], index_nm, dataset_id)
except Exception:
logging.exception("delete_skill: update parent children_kwd failed kb=%s parent=%s", dataset_id, parent_kwd)
# ------------------------------------------------------------------
# 5. Prune and rewrite skill_all tree
# ------------------------------------------------------------------
try:
deleted_set = set(all_kwds)
pruned_tree = _prune_skill_tree(tree, deleted_set)
skill_all_row["skill_with_weight"] = json.dumps(pruned_tree, ensure_ascii=False, indent=2)
settings.docStoreConn.insert([skill_all_row], index_nm, dataset_id)
except Exception:
logging.exception("delete_skill: rewrite skill_all failed kb=%s skill=%s", dataset_id, skill_kwd)
return False, "Failed to update skill tree."
return True, {"deleted": int(deleted or 0)}

View File

@@ -266,7 +266,7 @@ def build_skill_md(node: "SkillNode") -> str:
return "\n".join(lines)
def skill_node_es_row(ctx: TaskContext, node: "SkillNode") -> Dict:
def skill_node_es_row(ctx: TaskContext, node: "SkillNode", parent_kwd: str = "") -> Dict:
"""Build the ES row for one tree node. Stable id from
(kb_id, folder_name) so re-runs upsert cleanly."""
kb_id_str = str(ctx.kb_id)
@@ -279,6 +279,7 @@ def skill_node_es_row(ctx: TaskContext, node: "SkillNode") -> Dict:
"doc_id": kb_id_str, # KB-scoped sentinel
"compile_kwd": "skill",
"skill_kwd": node.folder_name,
"parent_kwd": parent_kwd,
"depth_int": int(node.level),
"children_kwd": [c.folder_name for c in node.children],
"source_doc_ids": list(node.doc_ids),
@@ -287,6 +288,14 @@ def skill_node_es_row(ctx: TaskContext, node: "SkillNode") -> Dict:
}
def _collect_skill_rows(ctx: TaskContext, node: "SkillNode", parent_kwd: str, out: list[dict]) -> None:
"""Depth-first traversal: build one ``skill`` row per node, passing
``parent_kwd`` down so every non-root node records its parent."""
out.append(skill_node_es_row(ctx, node, parent_kwd))
for child in node.children:
_collect_skill_rows(ctx, child, node.folder_name, out)
def skill_tree_md_snippet(node: "SkillNode") -> str:
"""Return only the frontmatter/preamble before the Overview body.
@@ -566,7 +575,9 @@ async def run_corpus2skill(
except Exception:
logging.debug("skill: prior delete failed; relying on id-upsert")
rows = [skill_node_es_row(ctx, n) for n in all_nodes]
rows: list[dict] = []
for r in roots:
_collect_skill_rows(ctx, r, "", rows)
rows.append(skill_all_es_row(ctx, roots))
if not rows:
progress(1.0, "skill: nothing to persist")