401 lines
17 KiB
Python
401 lines
17 KiB
Python
"""User-facing skill distillation APIs plus administrator maintenance APIs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from typing import Any, Literal
|
|
|
|
from croniter import croniter
|
|
from fastapi import APIRouter, HTTPException, Query, Request
|
|
from pydantic import BaseModel, Field, field_validator
|
|
|
|
from app.gateway.deps import get_optional_user_from_request
|
|
from app.gateway.skill_knowledge_job_executor import _skill_directory
|
|
from deerflow.config.system_settings import (
|
|
SkillKnowledgeAutoSyncSettings,
|
|
load_system_settings,
|
|
save_system_settings,
|
|
)
|
|
from deerflow.integrations.weknora.runtime import (
|
|
build_weknora_client,
|
|
get_resolved_llmwiki_runtime,
|
|
)
|
|
from deerflow.skill_knowledge import scan_skill
|
|
from deerflow.skills.storage import get_or_new_skill_storage
|
|
from deerflow.skills.types import SkillCategory
|
|
|
|
router = APIRouter(prefix="/api/skill-knowledge", tags=["skill-knowledge"])
|
|
|
|
|
|
async def _actor(request: Request) -> tuple[str, bool]:
|
|
user = await get_optional_user_from_request(request)
|
|
if user is None:
|
|
return "system", True
|
|
return str(user.id), getattr(user, "system_role", None) == "admin"
|
|
|
|
|
|
async def _require_admin(request: Request) -> str:
|
|
actor, is_admin = await _actor(request)
|
|
if not is_admin:
|
|
raise HTTPException(status_code=403, detail="技能知识归纳仅管理员可操作")
|
|
return actor
|
|
|
|
|
|
def _owned_by_actor(row: dict[str, Any] | None, actor: str, is_admin: bool) -> bool:
|
|
return bool(row and (is_admin or str(row.get("created_by") or "") == actor))
|
|
|
|
|
|
def _store(request: Request):
|
|
value = getattr(request.app.state, "skill_knowledge_store", None)
|
|
if value is None:
|
|
raise HTTPException(status_code=503, detail="技能知识归纳存储不可用")
|
|
return value
|
|
|
|
|
|
class SyncTarget(BaseModel):
|
|
target_type: Literal["weknora", "assistant"]
|
|
target_mode: Literal["wiki", "document", "faq", "conversation_archive", "readonly_sidecar", "assistant"]
|
|
target_id: str = Field(min_length=1, max_length=128)
|
|
target_name: str = Field(default="", max_length=255)
|
|
remote_business_key: str | None = Field(default=None, max_length=512)
|
|
readonly: bool = False
|
|
|
|
|
|
class CreateSyncJobRequest(BaseModel):
|
|
skill_names: list[str] = Field(min_length=1, max_length=200)
|
|
targets: list[SyncTarget] = Field(min_length=1, max_length=1)
|
|
force: bool = False
|
|
review_mode: Literal["required", "auto_high_confidence", "off"] = "off"
|
|
|
|
@field_validator("skill_names")
|
|
@classmethod
|
|
def validate_names(cls, values: list[str]) -> list[str]:
|
|
result = list(dict.fromkeys(value.strip() for value in values if value.strip()))
|
|
if not result:
|
|
raise ValueError("At least one skill is required")
|
|
return result
|
|
|
|
|
|
class ReviewRequest(BaseModel):
|
|
review_ids: list[str] = Field(min_length=1, max_length=500)
|
|
action: Literal["approve", "reject"]
|
|
edits: dict[str, dict[str, Any]] = Field(default_factory=dict)
|
|
reason: str | None = Field(default=None, max_length=1024)
|
|
|
|
|
|
class DetachRequest(BaseModel):
|
|
delete_contributions: bool
|
|
|
|
|
|
class AutoSyncSettingsUpdate(BaseModel):
|
|
watch_enabled: bool | None = None
|
|
schedule_enabled: bool | None = None
|
|
schedule_cron: str | None = Field(default=None, min_length=5, max_length=128)
|
|
debounce_seconds: int | None = Field(default=None, ge=10, le=86_400)
|
|
write_remote_enabled: bool | None = None
|
|
review_mode: Literal["required", "auto_high_confidence", "off"] | None = None
|
|
|
|
@field_validator("schedule_cron")
|
|
@classmethod
|
|
def validate_cron(cls, value: str | None) -> str | None:
|
|
if value is not None and not croniter.is_valid(value):
|
|
raise ValueError("无效的 cron 表达式")
|
|
return value
|
|
|
|
|
|
@router.get("/bindings")
|
|
async def list_bindings(
|
|
request: Request,
|
|
skill_names: list[str] | None = Query(default=None),
|
|
target_id: str | None = Query(default=None),
|
|
include_detached: bool = Query(default=False),
|
|
mine_only: bool = Query(default=False),
|
|
) -> dict[str, Any]:
|
|
actor, is_admin = await _actor(request)
|
|
rows = await _store(request).list_bindings(
|
|
skill_names=skill_names,
|
|
target_id=target_id,
|
|
include_detached=include_detached,
|
|
created_by=actor if mine_only or not is_admin else None,
|
|
)
|
|
return {"bindings": rows}
|
|
|
|
|
|
@router.get("/source-status")
|
|
async def source_status(request: Request, skill_names: list[str] = Query(...)) -> dict[str, Any]:
|
|
await _require_admin(request)
|
|
bindings = await _store(request).list_bindings(skill_names=skill_names)
|
|
by_skill: dict[str, dict[str, Any]] = {}
|
|
for name in list(dict.fromkeys(skill_names)):
|
|
try:
|
|
root = _skill_directory(request.app.state.config, name)
|
|
result = await asyncio.to_thread(scan_skill, name, root)
|
|
by_skill[name] = {"source_digest": result.source_digest, "counts": result.counts, "blocked_files": result.blocked_files}
|
|
except Exception as exc: # noqa: BLE001
|
|
by_skill[name] = {"error": str(exc)}
|
|
for binding in bindings:
|
|
status = by_skill.get(binding["skill_name"])
|
|
if status and status.get("source_digest") and status["source_digest"] != binding.get("synced_digest"):
|
|
binding["computed_status"] = "stale"
|
|
return {"skills": by_skill, "bindings": bindings}
|
|
|
|
|
|
@router.get("/auto-sync-settings", response_model=SkillKnowledgeAutoSyncSettings)
|
|
async def get_auto_sync_settings(request: Request) -> SkillKnowledgeAutoSyncSettings:
|
|
await _require_admin(request)
|
|
return load_system_settings().skill_knowledge_auto_sync
|
|
|
|
|
|
@router.put("/auto-sync-settings", response_model=SkillKnowledgeAutoSyncSettings)
|
|
async def update_auto_sync_settings(request: Request, body: AutoSyncSettingsUpdate) -> SkillKnowledgeAutoSyncSettings:
|
|
await _require_admin(request)
|
|
current = load_system_settings()
|
|
updated = current.skill_knowledge_auto_sync.model_copy(update=body.model_dump(exclude_unset=True))
|
|
saved = save_system_settings(current.model_copy(update={"skill_knowledge_auto_sync": updated}))
|
|
service = getattr(request.app.state, "skill_knowledge_auto_sync", None)
|
|
if service is not None:
|
|
service.nudge()
|
|
return saved.skill_knowledge_auto_sync
|
|
|
|
|
|
async def _set_binding_auto_sync(request: Request, binding_id: str, *, enabled: bool) -> dict[str, Any]:
|
|
await _require_admin(request)
|
|
row = await _store(request).set_binding_auto_sync(binding_id, enabled=enabled)
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="绑定不存在")
|
|
service = getattr(request.app.state, "skill_knowledge_auto_sync", None)
|
|
if service is not None:
|
|
service.nudge()
|
|
return row
|
|
|
|
|
|
@router.post("/bindings/{binding_id}/enable-auto-sync")
|
|
async def enable_binding_auto_sync(request: Request, binding_id: str) -> dict[str, Any]:
|
|
return await _set_binding_auto_sync(request, binding_id, enabled=True)
|
|
|
|
|
|
@router.post("/bindings/{binding_id}/disable-auto-sync")
|
|
async def disable_binding_auto_sync(request: Request, binding_id: str) -> dict[str, Any]:
|
|
return await _set_binding_auto_sync(request, binding_id, enabled=False)
|
|
|
|
|
|
@router.post("/sync-jobs", status_code=202)
|
|
async def create_sync_job(request: Request, body: CreateSyncJobRequest) -> dict[str, Any]:
|
|
actor, is_admin = await _actor(request)
|
|
# Built-in and legacy skills need not have an ownership row. Validate
|
|
# against the same running filesystem used by the skills list/executor.
|
|
missing = []
|
|
favorite_names: set[str] = set()
|
|
custom_root: Any = None
|
|
if not is_admin:
|
|
favorite_names = set(await request.app.state.skill_store.list_favorite_skill_names(actor))
|
|
storage_root = get_or_new_skill_storage(
|
|
app_config=request.app.state.config
|
|
).get_skills_root_path().resolve()
|
|
custom_root = (storage_root / SkillCategory.CUSTOM.value).resolve()
|
|
for name in body.skill_names:
|
|
try:
|
|
skill_path = await asyncio.to_thread(_skill_directory, request.app.state.config, name)
|
|
if not is_admin:
|
|
visible = await request.app.state.skill_store.get_visible(name, actor)
|
|
is_public_filesystem_skill = not skill_path.resolve().is_relative_to(custom_root)
|
|
if visible is None and name not in favorite_names and not is_public_filesystem_skill:
|
|
missing.append(name)
|
|
except (FileNotFoundError, ValueError):
|
|
missing.append(name)
|
|
if missing:
|
|
raise HTTPException(status_code=404, detail=f"技能不存在:{', '.join(missing[:10])}")
|
|
targets: list[dict[str, Any]] = []
|
|
for target in body.targets:
|
|
data = target.model_dump()
|
|
if target.target_type != "weknora":
|
|
raise HTTPException(status_code=422, detail="不支持技能直接归纳到助手知识库,请先归纳到 WeKnora 普通知识库")
|
|
if target.target_mode != "wiki":
|
|
raise HTTPException(status_code=422, detail="技能归纳目标仅支持 WeKnora Wiki 知识库")
|
|
mapping = await request.app.state.llmwiki_store.get_authorized(
|
|
target.target_id,
|
|
actor,
|
|
write=True,
|
|
is_admin=is_admin,
|
|
)
|
|
if mapping is None:
|
|
raise HTTPException(status_code=404, detail=f"普通知识库不存在或当前用户不可写:{target.target_id}")
|
|
data["target_name"] = target.target_name or str(mapping.get("name") or target.target_id)
|
|
data["remote_business_key"] = target.remote_business_key or f"weknora:kb:{mapping['weknora_id']}"
|
|
targets.append(data)
|
|
job = await _store(request).create_job(
|
|
skill_names=body.skill_names,
|
|
targets=targets,
|
|
created_by=actor,
|
|
force=body.force,
|
|
review_mode=body.review_mode if is_admin else "off",
|
|
)
|
|
dispatcher = getattr(request.app.state, "skill_knowledge_dispatcher", None)
|
|
if dispatcher is not None:
|
|
dispatcher.nudge()
|
|
return job
|
|
|
|
|
|
@router.get("/sync-jobs")
|
|
async def list_sync_jobs(
|
|
request: Request,
|
|
limit: int = Query(default=50, ge=1, le=200),
|
|
mine_only: bool = Query(default=False),
|
|
) -> dict[str, Any]:
|
|
actor, is_admin = await _actor(request)
|
|
return {
|
|
"jobs": await _store(request).list_jobs(
|
|
limit=limit,
|
|
created_by=actor if mine_only or not is_admin else None,
|
|
)
|
|
}
|
|
|
|
|
|
@router.get("/sync-jobs/{job_id}")
|
|
async def get_sync_job(request: Request, job_id: str) -> dict[str, Any]:
|
|
actor, is_admin = await _actor(request)
|
|
row = await _store(request).get_job(job_id)
|
|
if not _owned_by_actor(row, actor, is_admin):
|
|
raise HTTPException(status_code=404, detail="归纳任务不存在")
|
|
return row
|
|
|
|
|
|
@router.post("/sync-jobs/{job_id}/cancel")
|
|
async def cancel_sync_job(request: Request, job_id: str) -> dict[str, bool]:
|
|
actor, is_admin = await _actor(request)
|
|
row = await _store(request).get_job(job_id)
|
|
if not _owned_by_actor(row, actor, is_admin):
|
|
raise HTTPException(status_code=404, detail="归纳任务不存在")
|
|
if not await _store(request).cancel_job(job_id):
|
|
raise HTTPException(status_code=404, detail="归纳任务不存在")
|
|
return {"canceled": True}
|
|
|
|
|
|
@router.post("/sync-items/{item_id}/retry", status_code=202)
|
|
async def retry_sync_item(request: Request, item_id: str) -> dict[str, Any]:
|
|
actor, is_admin = await _actor(request)
|
|
jobs = await _store(request).list_jobs(limit=200, created_by=None if is_admin else actor)
|
|
item = next(
|
|
(item for job in jobs for item in job.get("items", []) if item.get("id") == item_id),
|
|
None,
|
|
)
|
|
if item is None:
|
|
raise HTTPException(status_code=404, detail="归纳任务项不存在")
|
|
if item.get("target_type") == "weknora":
|
|
mapping = await request.app.state.llmwiki_store.get_authorized(
|
|
str(item["target_id"]), actor, write=True, is_admin=is_admin
|
|
)
|
|
if mapping is None:
|
|
raise HTTPException(status_code=404, detail="目标知识库不存在或当前用户不可写")
|
|
row = await _store(request).requeue_item(item_id)
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="归纳任务项不存在")
|
|
request.app.state.skill_knowledge_dispatcher.nudge()
|
|
return row
|
|
|
|
|
|
@router.post("/bindings/{binding_id}/resync", status_code=202)
|
|
async def resync_binding(request: Request, binding_id: str) -> dict[str, Any]:
|
|
actor, is_admin = await _actor(request)
|
|
binding = await _store(request).get_binding(binding_id)
|
|
if not _owned_by_actor(binding, actor, is_admin) or not binding.get("active"):
|
|
raise HTTPException(status_code=404, detail="绑定不存在")
|
|
if binding.get("target_type") == "weknora":
|
|
mapping = await request.app.state.llmwiki_store.get_authorized(
|
|
str(binding["target_id"]), actor, write=True, is_admin=is_admin
|
|
)
|
|
if mapping is None:
|
|
raise HTTPException(status_code=404, detail="目标知识库不存在或当前用户不可写")
|
|
target = {
|
|
"target_type": binding["target_type"],
|
|
"target_mode": binding["target_mode"],
|
|
"target_id": binding["target_id"],
|
|
"target_name": binding["target_name"],
|
|
"remote_business_key": binding.get("remote_business_key"),
|
|
}
|
|
job = await _store(request).create_job(
|
|
skill_names=[binding["skill_name"]],
|
|
targets=[target],
|
|
created_by=actor,
|
|
force=True,
|
|
review_mode="auto_high_confidence",
|
|
)
|
|
request.app.state.skill_knowledge_dispatcher.nudge()
|
|
return job
|
|
|
|
|
|
@router.post("/bindings/{binding_id}/detach")
|
|
async def detach_binding(request: Request, binding_id: str, body: DetachRequest) -> dict[str, Any]:
|
|
actor, is_admin = await _actor(request)
|
|
store = _store(request)
|
|
binding = await store.get_binding(binding_id)
|
|
if not _owned_by_actor(binding, actor, is_admin):
|
|
raise HTTPException(status_code=404, detail="绑定不存在")
|
|
if await store.has_active_item(binding_id):
|
|
raise HTTPException(status_code=409, detail="该绑定仍有运行中的归纳项,请先取消任务")
|
|
cleanup_failed = False
|
|
if body.delete_contributions:
|
|
try:
|
|
if binding["target_type"] == "assistant":
|
|
sources = await request.app.state.assistant_knowledge_store.list_sources()
|
|
for source in sources:
|
|
if source.get("source_type") == "skill" and source.get("source_key") == binding["skill_name"]:
|
|
await request.app.state.assistant_knowledge_store.delete_source(source["id"])
|
|
elif binding["target_type"] == "weknora":
|
|
mapping = await request.app.state.llmwiki_store.get_authorized(
|
|
str(binding["target_id"]),
|
|
actor,
|
|
write=True,
|
|
is_admin=is_admin,
|
|
)
|
|
runtime = get_resolved_llmwiki_runtime(request.app.state.config)
|
|
if mapping is None or not runtime.weknora_enabled:
|
|
raise RuntimeError("远端普通知识库当前不可写")
|
|
client = build_weknora_client(runtime)
|
|
remote_kb_id = str(mapping["weknora_id"])
|
|
for artifact in await store.list_artifacts(binding_id):
|
|
if artifact["kind"] == "wiki_page" and artifact.get("slug"):
|
|
await client.delete_wiki_page(remote_kb_id, artifact["slug"])
|
|
elif artifact.get("remote_id") and artifact["kind"] in {
|
|
"raw_source",
|
|
"summary_document",
|
|
"faq_projection",
|
|
"conversation_archive",
|
|
"graph_projection",
|
|
}:
|
|
await client.delete_document(str(artifact["remote_id"]))
|
|
except Exception: # noqa: BLE001
|
|
cleanup_failed = True
|
|
row = await store.detach_binding(
|
|
binding_id,
|
|
delete_contributions=body.delete_contributions,
|
|
cleanup_failed=cleanup_failed,
|
|
)
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="绑定不存在")
|
|
return row
|
|
|
|
|
|
@router.get("/reviews")
|
|
async def list_reviews(
|
|
request: Request,
|
|
job_id: str | None = Query(default=None),
|
|
item_id: str | None = Query(default=None),
|
|
status: str | None = Query(default="pending"),
|
|
) -> dict[str, Any]:
|
|
await _require_admin(request)
|
|
return {"reviews": await _store(request).list_reviews(job_id=job_id, item_id=item_id, status=status)}
|
|
|
|
|
|
@router.post("/reviews/actions")
|
|
async def review_actions(request: Request, body: ReviewRequest) -> dict[str, Any]:
|
|
actor = await _require_admin(request)
|
|
item_ids = await _store(request).review(body.review_ids, action=body.action, actor=actor, edits=body.edits, reason=body.reason)
|
|
resumed: list[str] = []
|
|
executor = request.app.state.skill_knowledge_executor
|
|
for item_id in item_ids:
|
|
if await _store(request).pending_review_count(item_id) == 0:
|
|
asyncio.create_task(executor.resume_reviewed_item(item_id))
|
|
resumed.append(item_id)
|
|
return {"updated": len(body.review_ids), "resumed_item_ids": resumed}
|