968 lines
36 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import hashlib
import json
import re
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
from config import WIKI_CONFIG
from wiki_engine import db
from wiki_engine.chunk_enhancer import enhance_slices
from wiki_engine.htknow_client import HTKnowClient
from wiki_engine.wiki_builder import build_pages_for_file
_FILE_SLICE_TEXT_CACHE: Dict[str, Dict[str, str]] = {}
async def sync_file(
file_id: str,
kb_id: Optional[str] = None,
file_info: Optional[Dict[str, Any]] = None,
use_llm: bool = False,
job_id: Optional[str] = None,
force: bool = False,
) -> Dict[str, Any]:
client = HTKnowClient()
file_info = dict(file_info or {})
filename = str(file_info.get("filename") or file_info.get("name") or "")
if job_id:
await db.update_job(
job_id,
stage="fetch_file",
current_file_id=str(file_id),
current_filename=filename,
)
if not file_info or not (file_info.get("filename") or file_info.get("name")):
try:
file_info.update(await client.get_file(str(file_id)))
except Exception as exc:
print(f"[wiki_engine] get file detail failed for {file_id}: {exc}")
file_info.setdefault("id", str(file_id))
file_info.setdefault("file_id", str(file_id))
if kb_id:
file_info.setdefault("kb_id", str(kb_id))
filename = str(file_info.get("filename") or file_info.get("name") or "")
if job_id:
await db.update_job(job_id, stage="fetch_slices", current_filename=filename)
slices = await client.get_file_slices(str(file_id), limit=WIKI_CONFIG.get("max_slices_per_file"))
if job_id:
await db.update_job(job_id, stage="enhance_slices")
refs = enhance_slices(
slices,
file_info=file_info,
max_slices=int(WIKI_CONFIG.get("max_slices_per_file") or 300),
)
if not refs:
raise ValueError(f"no slices returned for file {file_id}; keep existing wiki data unchanged")
content_signature = _file_content_signature(refs)
existing_state = await db.get_file_state(str(file_id))
existing_page_count = await db.count_file_pages(str(file_id)) if existing_state else 0
if not force and _is_unchanged_file(existing_state, refs, content_signature) and existing_page_count > 0:
if job_id:
await db.update_job(job_id, stage="skipped_unchanged")
return {
"success": True,
"skipped": True,
"reason": "unchanged",
"file_id": str(file_id),
"kb_id": str(kb_id or file_info.get("kb_id") or ""),
"slice_count": len(refs),
"embedding_count": 0,
"embedding_error": "",
"page_count": existing_page_count,
"page_ids": [],
}
source_hash = str(file_info.get("hash") or "")
metadata = file_info.get("metadata") if isinstance(file_info.get("metadata"), dict) else {}
metadata = dict(metadata)
metadata["content_signature"] = content_signature
metadata["source_hash"] = source_hash
metadata["wiki_pipeline_version"] = "wiki-sync-v3"
file_info["metadata"] = metadata
file_info["content_signature"] = content_signature
if not source_hash:
file_info["hash"] = content_signature
await db.upsert_file_state(file_info, slice_count=len(refs))
if job_id:
await db.update_job(job_id, stage="write_slices")
await db.replace_file_slice_refs(str(file_id), refs)
if job_id:
await db.update_job(job_id, stage="build_pages")
async def progress(stage: str) -> None:
if job_id and WIKI_CONFIG.get("stage_progress_enabled", True):
await db.update_job(job_id, stage=stage)
pages = await build_pages_for_file(file_info, refs, use_llm=use_llm, progress=progress if job_id else None)
if job_id:
await db.update_job(job_id, stage="write_pages")
page_ids = []
for page in pages:
page_id = await _upsert_page_with_slug_lock(page, owner=job_id or f"sync:{file_id}")
page_ids.append(page_id)
await db.replace_page_slices(page_id, page.get("links", []))
if job_id:
await db.update_job(job_id, stage="file_done")
return {
"success": True,
"skipped": False,
"file_id": str(file_id),
"kb_id": str(kb_id or file_info.get("kb_id") or ""),
"slice_count": len(refs),
"embedding_count": 0,
"embedding_error": "",
"page_count": len(page_ids),
"page_ids": page_ids,
}
async def sync_kb(
kb_id: str,
limit: Optional[int] = None,
use_llm: bool = False,
job_id: Optional[str] = None,
force: bool = False,
) -> Dict[str, Any]:
limit = int(limit or WIKI_CONFIG.get("default_sync_limit") or 20)
client = HTKnowClient()
files = await client.list_files(kb_id=kb_id, limit=limit)
if job_id:
await db.update_job(job_id, total_files=len(files))
processed = 0
total_slices = 0
skipped = 0
page_count = 0
errors: List[Dict[str, str]] = []
for file_info in files:
file_id = str(file_info.get("id") or file_info.get("file_id") or "")
if not file_id:
continue
try:
result = await sync_file(file_id, kb_id=kb_id, file_info=file_info, use_llm=use_llm, job_id=job_id, force=force)
processed += 1
total_slices += int(result.get("slice_count") or 0)
skipped += 1 if result.get("skipped") else 0
if not result.get("skipped"):
page_count += int(result.get("page_count") or 0)
except Exception as exc:
errors.append({"file_id": file_id, "error": str(exc)})
if job_id:
await db.update_job(
job_id,
processed_files=processed,
total_slices=total_slices,
skipped_files=skipped,
page_count=page_count,
)
await asyncio.sleep(0)
if job_id and WIKI_CONFIG.get("finalize_enabled", True):
await enqueue_finalize_job(kb_id, job_id)
return {
"success": not errors,
"kb_id": str(kb_id),
"file_count": len(files),
"processed_files": processed,
"total_slices": total_slices,
"skipped_files": skipped,
"page_count": page_count,
"errors": errors[:20],
}
async def start_sync_job(
target_type: str,
target_id: str,
kb_id: Optional[str] = None,
limit: Optional[int] = None,
use_llm: bool = False,
force: bool = False,
) -> str:
job_id = str(uuid.uuid4())
await db.create_job(job_id, target_type=target_type, target_id=target_id, kb_id=kb_id or "")
if WIKI_CONFIG.get("queue_enabled", True):
if target_type == "file":
await db.set_job_totals(job_id, 1)
await db.enqueue_wiki_file_op(
job_id=job_id,
kb_id=kb_id or "",
file_id=target_id,
file_info={},
use_llm=use_llm,
force=force,
)
return job_id
if target_type == "kb":
client = HTKnowClient()
files = await client.list_files(kb_id=target_id, limit=limit or WIKI_CONFIG.get("default_sync_limit"))
valid_files = [
(str(file_info.get("id") or file_info.get("file_id") or ""), file_info)
for file_info in files
if str(file_info.get("id") or file_info.get("file_id") or "")
]
await db.set_job_totals(job_id, len(valid_files))
if not valid_files:
await db.update_job(job_id, status="success")
return job_id
for file_id, file_info in valid_files:
await db.enqueue_wiki_file_op(
job_id=job_id,
kb_id=target_id,
file_id=file_id,
file_info=file_info,
use_llm=use_llm,
force=force,
)
return job_id
async def _runner() -> None:
try:
if target_type == "file":
await sync_file(target_id, kb_id=kb_id, use_llm=use_llm, job_id=job_id, force=force)
elif target_type == "kb":
await sync_kb(target_id, limit=limit, use_llm=use_llm, job_id=job_id, force=force)
else:
raise ValueError(f"unsupported target_type: {target_type}")
await db.update_job(job_id, status="success")
except Exception as exc:
await db.update_job(job_id, status="failed", error=str(exc))
asyncio.create_task(_runner())
return job_id
async def get_queue_stats() -> Dict[str, Any]:
return await db.get_wiki_queue_stats()
async def get_sync_job(job_id: str) -> Optional[Dict[str, Any]]:
return await db.get_job(job_id)
async def enqueue_finalize_job(kb_id: str, job_id: str = "") -> Dict[str, Any]:
if not WIKI_CONFIG.get("finalize_enabled", True):
return {"success": True, "enqueued": False, "reason": "disabled"}
op_id = await db.enqueue_wiki_finalize_op(
job_id=job_id,
kb_id=kb_id,
delay_seconds=int(WIKI_CONFIG.get("finalize_debounce_seconds") or 20),
)
return {"success": True, "enqueued": bool(op_id), "op_id": op_id}
async def finalize_kb(kb_id: str, job_id: str = "") -> Dict[str, Any]:
if job_id:
await db.update_job(job_id, stage="finalize")
page_count = await db.count_pages_for_kb(kb_id)
if job_id:
await db.update_job(job_id, stage="completed", page_count=page_count, current_file_id="", current_filename="")
return {"success": True, "kb_id": str(kb_id or ""), "page_count": page_count}
async def search_wiki(
query: str,
kb_id: Optional[str] = None,
limit: int = 8,
include_slices: bool = True,
) -> Dict[str, Any]:
page_limit = max(int(WIKI_CONFIG.get("page_search_top_k") or limit or 8), limit)
pages = await db.search_pages(query=query, kb_id=kb_id, limit=page_limit)
slice_map = {}
if include_slices and pages:
slice_map = await db.get_page_slices(
[int(p["id"]) for p in pages],
max_slices_per_page=int(WIKI_CONFIG.get("evidence_slices_per_page") or 6),
)
items = []
for rank, page in enumerate(pages, start=1):
page_id = int(page["id"])
slices = slice_map.get(page_id, [])
related_slice_ids = [str(s.get("slice_id")) for s in slices if s.get("slice_id")]
related_file_ids = list({
str(s.get("file_id"))
for s in slices
if s.get("file_id")
} | set([str(x) for x in (page.get("related_file_ids") or []) if x]))
items.append({
"source_type": "wiki",
"source_id": str(page_id),
"kb_id": str(page.get("kb_id") or ""),
"rank_sources": {"page": rank},
"slug": page.get("slug", ""),
"title": page.get("title", ""),
"page_type": page.get("page_type", ""),
"summary": page.get("summary", ""),
"content": page.get("content", ""),
"keywords": page.get("keywords", []),
"aliases": page.get("aliases", []),
"score": page.get("score", 0.0),
"related_file_ids": related_file_ids,
"related_slice_ids": related_slice_ids,
"slices": slices,
})
items = _fuse_wiki_items(items, limit=max(limit, 1))
await db.log_query(query, kb_id, len(items))
return {
"success": True,
"query": query,
"kb_id": kb_id or "",
"items": items,
"debug": {"page_hits": len(pages), "vector_hits": 0, "text_hits": 0},
}
async def evidence_search(query: str, kb_id: Optional[str] = None, limit: int = 5) -> Dict[str, Any]:
result = await search_wiki(
query=query,
kb_id=kb_id,
limit=max(limit * 2, limit),
include_slices=True,
)
evidence_items = []
for item in result.get("items", []):
slices = await _hydrate_wiki_slices(item.get("slices", []))
slice_texts = []
for s in slices[:6]:
text = str(s.get("content") or s.get("brief") or "").strip()
if not text:
continue
filename = str(s.get("filename") or "").strip()
title_path = " / ".join(str(x) for x in (s.get("title_path") or []) if x)
prefix_parts = [x for x in (filename, title_path) if x]
prefix = f"{' - '.join(prefix_parts)}\n" if prefix_parts else ""
slice_texts.append(
f"{prefix}{text}\n"
f"(file_id={s.get('file_id') or ''}, slice_id={s.get('slice_id') or ''})"
)
deep_text = "\n\n".join(slice_texts)
page_text = "\n".join(x for x in [
str(item.get("summary") or "").strip(),
str(item.get("content") or "").strip(),
] if x).strip()
text = "\n\n".join(x for x in [page_text, deep_text] if x).strip()
if not text:
continue
evidence_items.append({
"source_type": "wiki",
"source_id": item.get("source_id"),
"title": item.get("title"),
"text": text,
"score": item.get("score", 0.0),
"kb_id": item.get("kb_id", ""),
"metadata": {
"kb_id": item.get("kb_id", ""),
"page_type": item.get("page_type"),
"keywords": item.get("keywords", []),
"related_file_ids": item.get("related_file_ids", []),
"related_slice_ids": item.get("related_slice_ids", []),
"slices": slices,
"rank_sources": item.get("rank_sources", {}),
},
})
# Keep the page-search order here. The baike workflow does a later cross-source
# rerank, and preserving the Wiki top hit makes the displayed Wiki evidence
# match `/api/wiki/search`.
evidence_items.sort(key=lambda x: float(x.get("score") or 0.0), reverse=True)
return {"success": True, "query": query, "items": evidence_items[:limit], "debug": result.get("debug", {})}
def _fuse_wiki_items(items: List[Dict[str, Any]], limit: int) -> List[Dict[str, Any]]:
rrf_k = max(int(WIKI_CONFIG.get("rrf_k") or 60), 1)
by_key: Dict[str, Dict[str, Any]] = {}
for item in items:
key = str(item.get("source_id") or item.get("title") or "")
if not key:
continue
fused_score = float(item.get("score") or 0.0)
for rank in (item.get("rank_sources") or {}).values():
try:
fused_score += 1.0 / (rrf_k + int(rank))
except Exception:
continue
item = dict(item)
item["score"] = fused_score
existing = by_key.get(key)
if existing:
merged_sources = dict(existing.get("rank_sources") or {})
for source, rank in (item.get("rank_sources") or {}).items():
if source not in merged_sources or int(rank) < int(merged_sources.get(source) or 999999):
merged_sources[source] = rank
item["rank_sources"] = merged_sources
fused_score = max(fused_score, float(existing.get("score") or 0.0))
for rank in merged_sources.values():
try:
fused_score += 1.0 / (rrf_k + int(rank))
except Exception:
continue
item["score"] = fused_score
if not existing or fused_score > float(existing.get("score") or 0.0):
by_key[key] = item
return sorted(by_key.values(), key=lambda x: float(x.get("score") or 0.0), reverse=True)[:limit]
async def _rerank_wiki_evidence(query: str, items: List[Dict[str, Any]], top_k: int) -> List[Dict[str, Any]]:
if not query.strip() or not items:
return items[:top_k]
try:
from modelsAPI.model_api import OpenaiAPI
selected = items[:24]
documents = [
"\n".join([
str(item.get("title") or ""),
str(item.get("text") or ""),
])[:4000]
for item in selected
]
rows = await OpenaiAPI.rerank(query=query, documents=documents, top_k=top_k)
if not rows:
return items[:top_k]
ranked: List[Dict[str, Any]] = []
used = set()
for row in rows:
idx = int(row.get("index"))
if idx < 0 or idx >= len(selected) or idx in used:
continue
item = dict(selected[idx])
item["rerank_score"] = float(row.get("relevance_score") or 0.0)
item["score"] = max(float(item.get("score") or 0.0), item["rerank_score"])
ranked.append(item)
used.add(idx)
for idx, item in enumerate(selected):
if idx not in used and len(ranked) < top_k:
ranked.append(item)
return ranked[:top_k]
except Exception as exc:
print(f"[wiki_engine] wiki evidence rerank failed: {exc}")
return items[:top_k]
async def _hydrate_wiki_slices(slices: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Fill missing slice content from 11000 for old Wiki rows."""
out = [dict(s or {}) for s in slices]
missing_by_file: Dict[str, set[str]] = {}
for s in out:
if str(s.get("content") or "").strip():
continue
file_id = str(s.get("file_id") or "").strip()
slice_id = str(s.get("slice_id") or "").strip()
if file_id and slice_id:
missing_by_file.setdefault(file_id, set()).add(slice_id)
if not missing_by_file:
return out
client = HTKnowClient()
fetched: Dict[str, str] = {}
for file_id, wanted_ids in missing_by_file.items():
cached = _FILE_SLICE_TEXT_CACHE.get(file_id)
if cached is not None:
fetched.update({sid: cached.get(sid, "") for sid in wanted_ids})
continue
try:
raw_slices = await client.get_file_slices(file_id, limit=WIKI_CONFIG.get("max_slices_per_file"))
except Exception as exc:
print(f"[wiki_engine] hydrate slices failed for file {file_id}: {exc}")
continue
file_texts: Dict[str, str] = {}
for idx, raw in enumerate(raw_slices):
sid = str(raw.get("id") or raw.get("slice_id") or raw.get("uuid") or f"{file_id}:{idx}")
text = _raw_slice_text(raw)
if text:
file_texts[sid] = text
if sid in wanted_ids:
fetched[sid] = text
_FILE_SLICE_TEXT_CACHE[file_id] = file_texts
for s in out:
sid = str(s.get("slice_id") or "")
if sid in fetched and fetched[sid]:
s["content"] = fetched[sid]
return out
def _file_content_signature(refs: List[Dict[str, Any]]) -> str:
rows = []
for ref in sorted(refs, key=lambda x: (int(x.get("ordinal") or 0), str(x.get("slice_id") or ""))):
rows.append({
"slice_id": str(ref.get("slice_id") or ""),
"content_hash": str(ref.get("content_hash") or ""),
"title_path": ref.get("title_path") or [],
"keywords": ref.get("keywords") or [],
})
raw = json.dumps(rows, ensure_ascii=False, sort_keys=True)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _is_unchanged_file(
state: Optional[Dict[str, Any]],
refs: List[Dict[str, Any]],
content_signature: str,
) -> bool:
if not state:
return False
if int(state.get("slice_count") or 0) != len(refs):
return False
metadata = state.get("metadata") if isinstance(state.get("metadata"), dict) else {}
stored_signature = str(metadata.get("content_signature") or "")
if stored_signature and stored_signature == content_signature:
return True
return str(state.get("file_hash") or "") == content_signature
async def _upsert_page_with_slug_lock(page: Dict[str, Any], owner: str) -> int:
kb_id = str(page.get("kb_id") or "")
slug = str(page.get("slug") or "")
if not kb_id or not slug:
return await db.upsert_wiki_page(page)
ttl = int(WIKI_CONFIG.get("slug_lock_ttl_seconds") or 600)
acquired = False
for _ in range(600):
acquired = await db.acquire_wiki_page_lock(kb_id, slug, owner=owner, ttl_seconds=ttl)
if acquired:
break
await asyncio.sleep(0.1)
if not acquired:
raise TimeoutError(f"wiki page lock timeout: {kb_id}/{slug}")
try:
return await db.upsert_wiki_page(page)
finally:
await db.release_wiki_page_lock(kb_id, slug, owner=owner)
def _raw_slice_text(raw: Dict[str, Any]) -> str:
for key in ("content", "text", "page_content", "slice_content", "markdown"):
value = raw.get(key)
if isinstance(value, str) and value.strip():
return value
return ""
async def get_page(page_id: int) -> Optional[Dict[str, Any]]:
return await db.get_page(page_id)
async def get_stats() -> Dict[str, Any]:
return await db.get_stats()
async def get_kb_tree() -> Dict[str, Any]:
client = HTKnowClient()
tree = await client.get_kb_tree()
return {"success": True, "tree": tree}
async def resolve_kb_reference(reference: str) -> Dict[str, Any]:
"""Resolve a user-facing knowledge-base name or admin id to a kb_id."""
ref = str(reference or "").strip()
if not ref:
return {"success": False, "error": "knowledge base name is empty", "matches": []}
client = HTKnowClient()
tree = await client.get_kb_tree()
nodes = _flatten_kb_tree(tree)
ref_norm = _normalize_kb_text(ref)
scored: List[Dict[str, Any]] = []
for node in nodes:
node_id = str(node.get("id") or "")
names = _node_names(node)
best_score = 0
best_name = names[0] if names else ""
if node_id and node_id == ref:
best_score = 10000
for name in names:
name_norm = _normalize_kb_text(name)
if not name_norm:
continue
score = 0
if name_norm == ref_norm:
score = 9000 + len(name_norm)
elif name_norm in ref_norm:
score = 7000 + len(name_norm)
elif ref_norm in name_norm:
score = 5000 + len(ref_norm)
if score > best_score:
best_score = score
best_name = name
if best_score:
scored.append({
"kb_id": node_id,
"name": best_name,
"path": node.get("_path", best_name),
"score": best_score,
})
scored.sort(key=lambda item: (-int(item.get("score") or 0), -len(str(item.get("path") or ""))))
if not scored:
return {
"success": False,
"error": f"未找到名为“{ref}”的知识库",
"matches": [],
"candidates": [
{"kb_id": str(node.get("id") or ""), "name": (_node_names(node) or [""])[0], "path": node.get("_path", "")}
for node in nodes[:20]
],
}
top_score = int(scored[0].get("score") or 0)
top_matches = [item for item in scored if int(item.get("score") or 0) == top_score]
if len({item.get("kb_id") for item in top_matches}) > 1 and top_score < 10000:
return {
"success": False,
"error": f"知识库名称“{ref}”匹配到多个结果,请说得更完整一点",
"matches": top_matches[:10],
}
best = scored[0]
return {
"success": True,
"kb_id": str(best.get("kb_id") or ""),
"kb_name": str(best.get("name") or ""),
"kb_path": str(best.get("path") or ""),
"matches": scored[:10],
}
async def diff_kb(
kb_id: str,
include_children: bool = False,
limit: int = 200,
parsed_only: bool = True,
) -> Dict[str, Any]:
client = HTKnowClient()
kb_ids = await _resolve_kb_ids(client, kb_id, include_children=include_children)
ht_files: Dict[str, Dict[str, Any]] = {}
skipped_files: List[Dict[str, Any]] = []
for kid in kb_ids:
files = await client.list_files(kb_id=kid, limit=max(int(limit or 200), 1))
for file_info in files:
fid = str(file_info.get("id") or file_info.get("file_id") or "")
if not fid:
continue
if parsed_only and str(file_info.get("status")) != "1":
skipped_files.append(_compact_file(file_info))
continue
file_info["file_id"] = fid
file_info["kb_id"] = str(file_info.get("kb_id") or kid)
ht_files[fid] = file_info
wiki_states = await db.list_file_states(kb_ids)
wiki_by_file = {str(item.get("file_id")): item for item in wiki_states}
missing_in_wiki = []
stale_in_wiki = []
for fid, file_info in ht_files.items():
state = wiki_by_file.get(fid)
if not state:
missing_in_wiki.append(_compact_file(file_info))
elif _is_stale(file_info, state):
stale_in_wiki.append({
"file_id": fid,
"kb_id": str(file_info.get("kb_id") or ""),
"filename": str(file_info.get("filename") or ""),
"htknow_hash": str(file_info.get("hash") or ""),
"wiki_hash": str(state.get("file_hash") or ""),
"htknow_updated_at": str(file_info.get("updated_at") or ""),
"wiki_source_updated_at": str(state.get("source_updated_at") or ""),
"last_synced_at": state.get("last_synced_at") or "",
})
orphan_in_wiki = []
for fid, state in wiki_by_file.items():
if fid not in ht_files:
orphan_in_wiki.append({
"file_id": fid,
"kb_id": str(state.get("kb_id") or ""),
"filename": str(state.get("filename") or ""),
"last_synced_at": state.get("last_synced_at") or "",
})
return {
"success": True,
"kb_id": str(kb_id),
"include_children": include_children,
"kb_ids": kb_ids,
"summary": {
"htknow_files": len(ht_files),
"wiki_files": len(wiki_by_file),
"missing_in_wiki": len(missing_in_wiki),
"stale_in_wiki": len(stale_in_wiki),
"orphan_in_wiki": len(orphan_in_wiki),
"skipped_unparsed": len(skipped_files),
},
"missing_in_wiki": missing_in_wiki,
"stale_in_wiki": stale_in_wiki,
"orphan_in_wiki": orphan_in_wiki,
"skipped_unparsed": skipped_files[:50],
}
async def reconcile_kb(
kb_id: str,
include_children: bool = False,
limit: int = 200,
sync_missing: bool = True,
rebuild_stale: bool = True,
delete_orphan: bool = False,
use_llm: bool = False,
dry_run: bool = True,
) -> Dict[str, Any]:
diff = await diff_kb(kb_id=kb_id, include_children=include_children, limit=limit, parsed_only=True)
actions: List[Dict[str, Any]] = []
if sync_missing:
actions.extend({"action": "sync_file", "file_id": x["file_id"], "kb_id": x.get("kb_id", "")} for x in diff["missing_in_wiki"])
if rebuild_stale:
actions.extend({"action": "rebuild_file", "file_id": x["file_id"], "kb_id": x.get("kb_id", "")} for x in diff["stale_in_wiki"])
if delete_orphan:
actions.extend({"action": "delete_wiki_file", "file_id": x["file_id"], "kb_id": x.get("kb_id", "")} for x in diff["orphan_in_wiki"])
executed: List[Dict[str, Any]] = []
if not dry_run:
client = HTKnowClient()
for action in actions:
try:
if action["action"] in {"sync_file", "rebuild_file"}:
file_info = await client.get_file(action["file_id"])
if action.get("kb_id"):
file_info["kb_id"] = action["kb_id"]
result = await sync_file(
action["file_id"],
kb_id=action.get("kb_id"),
file_info=file_info,
use_llm=use_llm,
force=action["action"] == "rebuild_file",
)
elif action["action"] == "delete_wiki_file":
result = await delete_by_file(action["file_id"])
else:
result = {"success": False, "error": "unknown action"}
executed.append({"action": action, "result": result})
except Exception as exc:
executed.append({"action": action, "result": {"success": False, "error": str(exc)}})
return {
"success": True,
"dry_run": dry_run,
"kb_id": str(kb_id),
"include_children": include_children,
"summary": diff.get("summary", {}),
"planned_actions": actions,
"executed": executed,
}
async def create_admin_plan(
chat_id: str,
intent: str,
scope: str,
actions: List[Dict[str, Any]],
kb_id: str = "",
kb_name: str = "",
summary: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
return await db.create_admin_plan(
plan_id=str(uuid.uuid4()),
chat_id=chat_id,
intent=intent,
scope=scope,
kb_id=kb_id,
kb_name=kb_name,
summary=summary or {},
actions=actions,
metadata=metadata or {},
)
async def get_pending_admin_plan(chat_id: str) -> Optional[Dict[str, Any]]:
return await db.get_latest_pending_admin_plan(chat_id)
async def cancel_admin_plan(plan_id: str) -> Optional[Dict[str, Any]]:
return await db.update_admin_plan_status(plan_id, "cancelled")
async def execute_admin_plan(
plan: Dict[str, Any],
selected_indexes: Optional[List[int]] = None,
use_llm: bool = False,
) -> Dict[str, Any]:
actions = plan.get("actions") or []
if selected_indexes:
selected = {idx for idx in selected_indexes if idx >= 1}
actions = [action for idx, action in enumerate(actions, start=1) if idx in selected]
executed: List[Dict[str, Any]] = []
client = HTKnowClient()
for action in actions:
try:
action_type = action.get("action")
if action_type in {"sync_file", "rebuild_file"}:
job_id = await start_sync_job(
target_type="file",
target_id=str(action.get("file_id") or ""),
kb_id=str(action.get("kb_id") or ""),
use_llm=bool(action.get("use_llm") or use_llm),
force=action_type == "rebuild_file",
)
job = await db.get_job(job_id)
result = {"success": True, "status": "running", "job_id": job_id, "job": job}
elif action_type == "delete_wiki_file":
result = await delete_by_file(str(action.get("file_id") or ""))
elif action_type == "delete_wiki_kb":
result = await delete_by_kb(str(action.get("kb_id") or plan.get("kb_id") or ""))
else:
result = {"success": False, "error": f"unknown action: {action_type}"}
executed.append({"action": action, "result": result})
except Exception as exc:
executed.append({"action": action, "result": {"success": False, "error": str(exc)}})
await asyncio.sleep(0)
failed = [item for item in executed if not item.get("result", {}).get("success")]
status = "failed" if failed else "executed"
await db.update_admin_plan_status(str(plan.get("id") or ""), status, executed=executed)
return {
"success": not failed,
"plan_id": plan.get("id", ""),
"status": status,
"executed_count": len(executed),
"failed_count": len(failed),
"executed": executed,
}
async def delete_by_file(file_id: str) -> Dict[str, Any]:
counts = await db.delete_wiki_by_file(file_id)
return {"success": True, "scope": "file", "file_id": str(file_id), "deleted": counts}
async def delete_by_kb(kb_id: str) -> Dict[str, Any]:
counts = await db.delete_wiki_by_kb(kb_id)
return {"success": True, "scope": "kb", "kb_id": str(kb_id), "deleted": counts}
async def delete_by_time(
before: Optional[datetime] = None,
after: Optional[datetime] = None,
kb_id: Optional[str] = None,
) -> Dict[str, Any]:
counts = await db.delete_wiki_by_time(before=before, after=after, kb_id=kb_id)
return {
"success": True,
"scope": "time",
"kb_id": str(kb_id or ""),
"before": before.isoformat() if before else "",
"after": after.isoformat() if after else "",
"deleted": counts,
}
async def _resolve_kb_ids(client: HTKnowClient, kb_id: str, include_children: bool = False) -> List[str]:
root = str(kb_id)
if not include_children:
return [root]
tree = await client.get_kb_tree()
found = _find_kb_node(tree, root)
if not found:
return [root]
ids: List[str] = []
_collect_kb_ids(found, ids)
return ids or [root]
def _find_kb_node(nodes: List[Dict[str, Any]], kb_id: str) -> Optional[Dict[str, Any]]:
for node in nodes:
if str(node.get("id")) == str(kb_id):
return node
children = node.get("children") or []
if isinstance(children, list):
found = _find_kb_node(children, kb_id)
if found:
return found
return None
def _collect_kb_ids(node: Dict[str, Any], out: List[str]) -> None:
kid = node.get("id")
if kid is not None:
out.append(str(kid))
children = node.get("children") or []
if isinstance(children, list):
for child in children:
if isinstance(child, dict):
_collect_kb_ids(child, out)
def _flatten_kb_tree(nodes: List[Dict[str, Any]], parent_path: str = "") -> List[Dict[str, Any]]:
flattened: List[Dict[str, Any]] = []
for node in nodes:
if not isinstance(node, dict):
continue
item = dict(node)
names = _node_names(item)
display_name = names[0] if names else str(item.get("id") or "")
item["_path"] = f"{parent_path}/{display_name}" if parent_path else display_name
flattened.append(item)
children = item.get("children") or []
if isinstance(children, list):
flattened.extend(_flatten_kb_tree(children, item["_path"]))
return flattened
def _node_names(node: Dict[str, Any]) -> List[str]:
keys = (
"name",
"title",
"label",
"kb_name",
"knowledge_base_name",
"knowledgeBaseName",
)
names: List[str] = []
for key in keys:
value = node.get(key)
if value is not None:
text = str(value).strip()
if text and text not in names:
names.append(text)
return names
def _normalize_kb_text(text: str) -> str:
return re.sub(r"[\s:,.。;\"'“”‘’《》<>()【】\\[\\]_-]+", "", str(text or "").lower())
def _compact_file(file_info: Dict[str, Any]) -> Dict[str, Any]:
return {
"file_id": str(file_info.get("file_id") or file_info.get("id") or ""),
"kb_id": str(file_info.get("kb_id") or ""),
"filename": str(file_info.get("filename") or file_info.get("name") or ""),
"hash": str(file_info.get("hash") or ""),
"status": str(file_info.get("status") or ""),
"updated_at": str(file_info.get("updated_at") or ""),
}
def _is_stale(file_info: Dict[str, Any], state: Dict[str, Any]) -> bool:
source_hash = str(file_info.get("hash") or "")
wiki_hash = str(state.get("file_hash") or "")
if source_hash and wiki_hash and source_hash != wiki_hash:
return True
source_updated = str(file_info.get("updated_at") or "")
wiki_source_updated = str(state.get("source_updated_at") or "")
if source_updated and wiki_source_updated and source_updated != wiki_source_updated:
return True
return False