Files
kefu/deploy/knowledge-20260916-100045/payload/wechat_rpa/knowledge_api.py
T
2026-09-21 10:34:06 +08:00

200 lines
9.8 KiB
Python

"""Tenant-scoped knowledge management routes."""
from __future__ import annotations
import json
from contextlib import contextmanager
from typing import Any, Literal
import httpx
from fastapi import Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from archive_store import tenant_scope
from knowledge_retriever import KnowledgeRetriever, VectorIndex
from knowledge_store import KnowledgeStore
class JobBody(BaseModel):
source_account_id: str = Field(min_length=1, max_length=64)
conversation_id: str = Field(default="", max_length=64)
date_from: str = ""
date_to: str = ""
engine: Literal["rules", "model"] = "rules"
max_messages: int = Field(default=10000, ge=1, le=1000000)
max_model_calls: int = Field(default=100, ge=1, le=10000)
staff_confirmed: bool = False
auto_watch: bool = False
class EditBody(BaseModel):
revision: int = Field(ge=1)
title: str = Field(min_length=1, max_length=160)
question: str = Field(min_length=1, max_length=4000)
answer: str = Field(min_length=1, max_length=8000)
conditions: str = Field(min_length=1, max_length=2000)
category: str = Field(default="", max_length=100)
kind: Literal["qa", "procedure", "case"] = "qa"
valid_until: str = ""
class ActionBody(BaseModel):
revision: int = Field(ge=1)
confirmed: bool = False
restore_revision: int = Field(default=0, ge=0)
class SearchBody(BaseModel):
query: str = Field(min_length=2, max_length=2000)
class SettingsBody(BaseModel):
enabled: bool
@contextmanager
def errors():
try:
yield
except KeyError as exc:
raise HTTPException(404, str(exc).strip("'")) from exc
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
except httpx.HTTPError as exc:
raise HTTPException(502, "知识索引服务连接失败,当前知识未发布,请检查服务配置后重试") from exc
def register_knowledge_routes(app, database, require, client_ip, resolve_admin_tenant, current_desktop):
store = KnowledgeStore(database)
store.initialize()
app.state.knowledge_store = store
def single(principal, account_id):
scope = resolve_admin_tenant(principal, account_id)
if not isinstance(scope, str) or scope == "*":
raise HTTPException(400, "请先选择具体的客户端账号")
return scope
@app.get("/api/v2/knowledge/overview")
def overview(account_id: int | None = None, principal=Depends(require("knowledge:read"))):
scope = resolve_admin_tenant(principal, account_id)
ts = tenant_scope(scope)
with database.connect() as db:
accounts = [dict(r) for r in db.execute(
f"SELECT id,tenant_id,display_name,external_account_id FROM archive_source_account WHERE {ts.clause()}", ts.params)]
return {"counts": store.stats(scope), "source_accounts": accounts, "watches": store.watches(scope),
"settings": store.settings(scope) if isinstance(scope, str) and scope != "*" else None,
"semantic_configured": VectorIndex().configured}
@app.put("/api/v2/knowledge/settings")
def settings(body: SettingsBody, request: Request, account_id: int | None = None,
principal=Depends(require("knowledge:publish"))):
return store.set_enabled(single(principal, account_id), body.enabled, principal.id, client_ip(request))
@app.put("/api/v2/knowledge/watch/{source_id}")
def watch(source_id: str, body: SettingsBody, request: Request, account_id: int | None = None,
principal=Depends(require("knowledge:write", "im:content:read"))):
with errors():
return store.set_watch(single(principal, account_id), source_id, body.enabled, principal.id, client_ip(request))
@app.get("/api/v2/knowledge/jobs")
def jobs(account_id: int | None = None, principal=Depends(require("knowledge:read"))):
return {"items": store.jobs(resolve_admin_tenant(principal, account_id))}
@app.post("/api/v2/knowledge/jobs")
def create_job(body: JobBody, request: Request, account_id: int | None = None,
principal=Depends(require("knowledge:write", "im:content:read"))):
with errors():
return store.create_job(single(principal, account_id), body.model_dump(), principal.id, client_ip(request))
@app.post("/api/v2/knowledge/jobs/{job_id}/{action}")
def job_action(job_id: str, action: str, request: Request, account_id: int | None = None,
principal=Depends(require("knowledge:write"))):
with errors():
return store.job_action(single(principal, account_id), job_id, action, principal.id, client_ip(request))
@app.get("/api/v2/knowledge/items")
def items(account_id: int | None = None, status: str = "", query: str = "",
page: int = Query(default=1, ge=1), page_size: int = Query(default=30, ge=1, le=100),
principal=Depends(require("knowledge:read"))):
return store.list_items(resolve_admin_tenant(principal, account_id), status, query, page, page_size)
@app.get("/api/v2/knowledge/items/{item_id}")
def detail(item_id: str, account_id: int | None = None, principal=Depends(require("knowledge:read"))):
with errors():
return store.detail(single(principal, account_id), item_id, sources="im:content:read" in principal.permissions)
@app.put("/api/v2/knowledge/items/{item_id}")
def edit(item_id: str, body: EditBody, request: Request, account_id: int | None = None,
principal=Depends(require("knowledge:write", "im:content:read"))):
with errors():
return store.save(single(principal, account_id), item_id, body.model_dump(), principal.id, client_ip(request))
@app.post("/api/v2/knowledge/items/{item_id}/{action}")
def transition(item_id: str, action: str, body: ActionBody, request: Request,
account_id: int | None = None, principal=Depends(require("knowledge:read"))):
permission = "knowledge:publish" if action in {"publish", "disable"} else (
"knowledge:write" if action == "restore" else "knowledge:review")
if permission not in principal.permissions or "im:content:read" not in principal.permissions:
raise HTTPException(403, "缺少知识操作或来源正文查看权限")
tenant = single(principal, account_id)
with errors():
if action == "publish":
return store.publish(tenant, item_id, body.revision, principal.id, client_ip(request))
if action == "restore":
with database.connect() as db:
store._item(db, tenant, item_id)
previous = db.execute("SELECT snapshot_json FROM knowledge_revision WHERE item_id=? AND revision=? "
"ORDER BY created_at DESC LIMIT 1", (item_id, body.restore_revision)).fetchone()
if previous is None:
raise KeyError("历史版本不存在")
values = json.loads(previous[0])
values["revision"] = body.revision
return store.save(tenant, item_id, values, principal.id, client_ip(request))
return store.transition(tenant, item_id, body.revision, action, principal.id, client_ip(request), body.confirmed)
@app.post("/api/v2/knowledge/search")
def search(body: SearchBody, account_id: int | None = None, principal=Depends(require("knowledge:read"))):
return KnowledgeRetriever(store).search(single(principal, account_id), body.query)
@app.get("/api/v2/knowledge/retrievals")
def retrievals(account_id: int | None = None, principal=Depends(require("knowledge:read", "stats:read"))):
scope = tenant_scope(resolve_admin_tenant(principal, account_id))
with database.connect() as db:
rows = db.execute(f"SELECT * FROM knowledge_retrieval_log WHERE {scope.clause()} "
"ORDER BY created_at DESC LIMIT 100", scope.params).fetchall()
return {"items": [{**dict(r), "hits": json.loads(r["hits_json"])} for r in rows]}
@app.get("/api/v2/knowledge/export")
def export(request: Request, account_id: int | None = None,
principal=Depends(require("knowledge:read", "knowledge:export"))):
tenant = single(principal, account_id)
with database.connect() as db:
database._audit(db, principal.id, "knowledge.export", f"tenant={tenant}", client_ip(request))
def stream():
cursor = ""
while True:
with database.connect() as db:
rows = db.execute("SELECT * FROM knowledge_item WHERE tenant_id=? AND status='published' AND id>? "
"AND (valid_until='' OR valid_until>=?) ORDER BY id LIMIT 200",
(tenant, cursor, KnowledgeRetriever._today())).fetchall()
if not rows:
return
cursor = rows[-1]["id"]
rows = [r for r in rows if store._fresh(db, r["id"], tenant)]
for row in rows:
yield json.dumps({"schema_version": "knowledge-v1", **{k: row[k] for k in
("id", "revision", "title", "question", "answer", "conditions", "category", "kind", "valid_until")}},
ensure_ascii=False) + "\n"
return StreamingResponse(stream(), media_type="application/x-ndjson",
headers={"Content-Disposition": 'attachment; filename="knowledge.jsonl"'})
@app.get("/api/v2/knowledge/desktop/summary")
def desktop_summary(account: Any = Depends(current_desktop)):
tenant = str(account["tenant_id"])
counts = store.stats(tenant)
return {"published": counts.get("published", 0), "enabled": bool(store.settings(tenant)["enabled"])}
return store